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::presentation::entity_json::storage_value_to_json;
22use crate::runtime::mvcc::current_connection_id;
23use crate::storage::query::ast::{BinOp, Expr, FieldRef, ReturningItem, UpdateTarget};
24use crate::storage::query::sql_lowering::{
25    effective_delete_filter, effective_insert_rows, effective_update_filter, fold_expr_to_value,
26};
27use crate::storage::query::unified::{
28    sys_key_collection, sys_key_created_at, sys_key_kind, sys_key_rid, sys_key_tenant,
29    sys_key_updated_at, UnifiedRecord, UnifiedResult,
30};
31use crate::storage::unified::MetadataValue;
32use crate::storage::Metadata;
33use std::collections::HashMap;
34use std::sync::Arc;
35
36use super::*;
37
38const UPDATE_APPLY_CHUNK_SIZE: usize = 2048;
39const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
40const TREE_METADATA_PREFIX: &str = "red.tree.";
41
42#[derive(Clone)]
43struct CompiledUpdateAssignment {
44    column: String,
45    expr: Expr,
46    compound_op: Option<BinOp>,
47    metadata_key: Option<&'static str>,
48    row_rule: Option<RowUpdateColumnRule>,
49}
50
51struct CompiledUpdatePlan {
52    static_field_assignments: Vec<(String, Value)>,
53    static_metadata_assignments: Vec<(String, MetadataValue)>,
54    dynamic_assignments: Vec<CompiledUpdateAssignment>,
55    row_contract_plan: Option<RowUpdateContractPlan>,
56    row_modified_columns: Vec<String>,
57    row_touches_unique_columns: bool,
58}
59
60#[derive(Default)]
61struct MaterializedUpdateAssignments {
62    dynamic_field_assignments: Vec<(String, Value)>,
63    dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
64}
65
66impl RedDBRuntime {
67    /// Issue #524 — public read of the in-memory chain tip. Returns `None`
68    /// when the collection is not a chain or has no rows (pre-genesis). On a
69    /// cold cache the first call falls back to a one-time scan so the HTTP
70    /// `GET /collections/:name/chain-tip` handler stays consistent with the
71    /// INSERT path after a restart.
72    pub fn chain_tip_for_collection(
73        &self,
74        collection: &str,
75    ) -> Option<crate::runtime::blockchain_kind::ChainTipFull> {
76        let store = self.inner.db.store();
77        if !crate::runtime::blockchain_kind::is_chain(&store, collection) {
78            return None;
79        }
80        let mut cache = self.inner.chain_tip_cache.lock();
81        if let Some(existing) = cache.get(collection) {
82            return Some(existing.clone());
83        }
84        let scanned = crate::runtime::blockchain_kind::chain_tip_full(&store, collection)?;
85        cache.insert(collection.to_string(), scanned.clone());
86        Some(scanned)
87    }
88
89    /// Issue #525 — walks the chain end-to-end, recomputes each block's hash
90    /// against the stored fields, and returns the verification outcome.  On
91    /// `ok == false` the integrity flag is persisted and the in-memory cache
92    /// is updated so subsequent INSERTs surface `ChainIntegrityBroken`.
93    ///
94    /// Returns `None` when the collection is absent or not a `KIND blockchain`.
95    pub fn verify_chain_for_collection(
96        &self,
97        collection: &str,
98    ) -> Option<crate::runtime::blockchain_kind::VerifyChainOutcome> {
99        let store = self.inner.db.store();
100        let outcome = crate::runtime::blockchain_kind::verify_chain_outcome(&store, collection)?;
101        if !outcome.ok {
102            crate::runtime::blockchain_kind::persist_integrity_flag(&store, collection, true);
103            self.inner
104                .chain_integrity_broken
105                .lock()
106                .insert(collection.to_string(), true);
107        }
108        Some(outcome)
109    }
110
111    /// Issue #525 — admin clears the `ChainIntegrityBroken` flag so the chain
112    /// accepts INSERTs again.  Returns `false` when the collection is not a
113    /// chain.
114    pub fn clear_chain_integrity_flag(&self, collection: &str) -> bool {
115        let store = self.inner.db.store();
116        if !crate::runtime::blockchain_kind::is_chain(&store, collection) {
117            return false;
118        }
119        crate::runtime::blockchain_kind::persist_integrity_flag(&store, collection, false);
120        self.inner
121            .chain_integrity_broken
122            .lock()
123            .insert(collection.to_string(), false);
124        true
125    }
126
127    /// Issue #525 — INSERT-time check.  Combines in-memory cache (fast path)
128    /// with a one-time scan of `red_config` on cold start so the flag survives
129    /// restart.
130    fn is_chain_integrity_broken(&self, collection: &str) -> bool {
131        {
132            let cache = self.inner.chain_integrity_broken.lock();
133            if let Some(v) = cache.get(collection) {
134                return *v;
135            }
136        }
137        let store = self.inner.db.store();
138        let persisted =
139            crate::runtime::blockchain_kind::is_integrity_broken_persisted(&store, collection)
140                .unwrap_or(false);
141        self.inner
142            .chain_integrity_broken
143            .lock()
144            .insert(collection.to_string(), persisted);
145        persisted
146    }
147
148    /// Issue #765 / S6 — lazily hydrate the integrity-tombstone cache from
149    /// `red_config` on first access. Returns `true` when at least one
150    /// tombstone range is present. Subsequent calls observe the cached state
151    /// flag (`1` empty / `2` present) and skip the store scan.
152    fn ensure_integrity_tombstones_loaded(&self) -> bool {
153        use std::sync::atomic::Ordering;
154        match self
155            .inner
156            .integrity_tombstones_state
157            .load(Ordering::Relaxed)
158        {
159            1 => return false,
160            2 => return true,
161            _ => {}
162        }
163        // Cold: load under the cache lock so a concurrent reader cannot
164        // observe a half-populated vector.
165        let mut guard = self.inner.integrity_tombstones.lock();
166        if self
167            .inner
168            .integrity_tombstones_state
169            .load(Ordering::Relaxed)
170            == 0
171        {
172            let ranges = crate::runtime::integrity_tombstone::load_ranges(&self.inner.db.store());
173            let present = !ranges.is_empty();
174            *guard = ranges;
175            self.inner
176                .integrity_tombstones_state
177                .store(if present { 2 } else { 1 }, Ordering::Relaxed);
178        }
179        self.inner
180            .integrity_tombstones_state
181            .load(Ordering::Relaxed)
182            == 2
183    }
184
185    /// Issue #765 / S6 — durably record an integrity tombstone over the
186    /// inclusive RID range `[lo, hi]` of `table` (the committed rows of an
187    /// input stream whose end-to-end SHA-256 digest did not match). The range
188    /// is persisted to `red_config` (survives restart) and folded into the
189    /// in-memory cache so the same process filters it immediately.
190    pub fn record_integrity_tombstone(&self, table: &str, lo: u64, hi: u64) {
191        use std::sync::atomic::Ordering;
192        self.ensure_integrity_tombstones_loaded();
193        let mut guard = self.inner.integrity_tombstones.lock();
194        guard.push(crate::runtime::integrity_tombstone::TombstoneRange::new(
195            table.to_string(),
196            lo,
197            hi,
198        ));
199        crate::runtime::integrity_tombstone::persist_ranges(&self.inner.db.store(), &guard);
200        self.inner
201            .integrity_tombstones_state
202            .store(2, Ordering::Relaxed);
203    }
204
205    /// Issue #765 / S6 — snapshot of the currently-cached tombstone ranges.
206    /// Intended for tests and forensic surfaces; the read path uses
207    /// [`Self::filter_integrity_tombstoned`] which avoids the clone.
208    pub fn integrity_tombstone_ranges(
209        &self,
210    ) -> Vec<crate::runtime::integrity_tombstone::TombstoneRange> {
211        self.ensure_integrity_tombstones_loaded();
212        self.inner.integrity_tombstones.lock().clone()
213    }
214
215    /// Issue #765 / S6 — drop tombstoned rows from a SELECT result in place.
216    /// Fast no-op (one relaxed atomic load) when no tombstone has ever been
217    /// recorded. Clears `pre_serialized_json` when any row is removed so the
218    /// fast-path JSON cannot leak a filtered row back onto the wire.
219    pub fn filter_integrity_tombstoned(&self, result: &mut UnifiedResult) {
220        if !self.ensure_integrity_tombstones_loaded() {
221            return;
222        }
223        let guard = self.inner.integrity_tombstones.lock();
224        if guard.is_empty() {
225            return;
226        }
227        let before = result.records.len();
228        result.records.retain(|record| {
229            !crate::runtime::integrity_tombstone::record_tombstoned(&guard, record)
230        });
231        if result.records.len() != before {
232            result.pre_serialized_json = None;
233        }
234    }
235
236    /// Phase 2.5.4: inject `CURRENT_TENANT()` into an INSERT when the
237    /// target table is tenant-scoped and the user's column list does
238    /// not already name the tenant column.
239    ///
240    /// Returns:
241    /// * `Ok(None)` — no injection needed (non-tenant table, or user
242    ///   supplied the column explicitly). Caller uses the original
243    ///   query unchanged.
244    /// * `Ok(Some(augmented))` — a cloned query with the tenant column
245    ///   + literal value appended to every row.
246    /// * `Err(..)` — table is tenant-scoped but no tenant is bound to
247    ///   the current session. Fails loudly so callers don't produce
248    ///   rows that RLS would then hide on read.
249    fn maybe_inject_tenant_column(&self, query: &InsertQuery) -> RedDBResult<Option<InsertQuery>> {
250        let Some(tenant_col) = self.tenant_column(&query.table) else {
251            return Ok(None);
252        };
253        // User already named the column (literal match) — trust them.
254        if query
255            .columns
256            .iter()
257            .any(|c| c.eq_ignore_ascii_case(&tenant_col))
258        {
259            return Ok(None);
260        }
261
262        // Phase 2 PG parity: dotted-path tenancy. When `tenant_col` is a
263        // nested key like `headers.tenant` we operate on the root
264        // column (`headers`) and set / add the nested path inside its
265        // JSON value. If the user named the root column we mutate in
266        // place; otherwise we create a fresh JSON column for every row.
267        if let Some(dot_pos) = tenant_col.find('.') {
268            let (root, tail) = tenant_col.split_at(dot_pos);
269            let tail = &tail[1..]; // drop leading '.'
270            return self.inject_dotted_tenant(query, root, tail);
271        }
272
273        let Some(tenant_id) = crate::runtime::impl_core::current_tenant() else {
274            return Err(RedDBError::Query(format!(
275                "INSERT into tenant-scoped table '{}' requires an active tenant — \
276                 run SET TENANT '<id>' first or name column '{}' explicitly",
277                query.table, tenant_col
278            )));
279        };
280
281        let mut augmented = query.clone();
282        augmented.columns.push(tenant_col);
283        let lit = Value::text(tenant_id.clone());
284        for row in augmented.values.iter_mut() {
285            row.push(lit.clone());
286        }
287        for row in augmented.value_exprs.iter_mut() {
288            row.push(crate::storage::query::ast::Expr::Literal {
289                value: lit.clone(),
290                span: crate::storage::query::ast::Span::synthetic(),
291            });
292        }
293        Ok(Some(augmented))
294    }
295
296    /// Dotted-path auto-fill — set `root.tail` to `CURRENT_TENANT()` on
297    /// every row. Mirrors `maybe_inject_tenant_column` but mutates
298    /// nested JSON instead of appending a flat column.
299    ///
300    /// Cases:
301    /// * Root column already in the INSERT list → mutate per-row JSON
302    ///   (parse, set path, re-serialize).
303    /// * Root column absent → create a fresh `{tail: tenant}` JSON
304    ///   object and append the root column to the INSERT.
305    fn inject_dotted_tenant(
306        &self,
307        query: &InsertQuery,
308        root: &str,
309        tail: &str,
310    ) -> RedDBResult<Option<InsertQuery>> {
311        let active_tenant = crate::runtime::impl_core::current_tenant();
312        let mut augmented = query.clone();
313        let root_idx = augmented
314            .columns
315            .iter()
316            .position(|c| c.eq_ignore_ascii_case(root));
317
318        if let Some(idx) = root_idx {
319            // User supplied the root column. Per-row: if the dotted
320            // tail is already present we trust the user (admin / bulk
321            // loader scenario); otherwise fill from the active
322            // tenant. An unbound tenant is only an error when some
323            // row actually needs filling.
324            for row in augmented.values.iter_mut() {
325                let Some(slot) = row.get_mut(idx) else {
326                    continue;
327                };
328                if dotted_tail_already_set(slot, tail) {
329                    continue;
330                }
331                let Some(tenant_id) = &active_tenant else {
332                    return Err(RedDBError::Query(format!(
333                        "INSERT into tenant-scoped table '{}' requires an active tenant — \
334                         run SET TENANT '<id>' first or set '{}.{}' explicitly in each row",
335                        query.table, root, tail
336                    )));
337                };
338                *slot = merge_dotted_tenant(slot.clone(), tail, tenant_id)?;
339            }
340            // Expression row is kept in sync by re-wrapping the
341            // mutated literal; the canonical path will re-evaluate
342            // against the same JSON shape.
343            for (row_idx, row) in augmented.value_exprs.iter_mut().enumerate() {
344                if let Some(slot) = row.get_mut(idx) {
345                    let new_value = augmented
346                        .values
347                        .get(row_idx)
348                        .and_then(|v| v.get(idx))
349                        .cloned()
350                        .unwrap_or(Value::Null);
351                    *slot = crate::storage::query::ast::Expr::Literal {
352                        value: new_value,
353                        span: crate::storage::query::ast::Span::synthetic(),
354                    };
355                }
356            }
357        } else {
358            // No root column in the INSERT list — auto-fill needs a
359            // bound tenant to synthesise one. Error loud so we never
360            // create a tenant-less row that RLS would then hide.
361            let Some(tenant_id) = &active_tenant else {
362                return Err(RedDBError::Query(format!(
363                    "INSERT into tenant-scoped table '{}' requires an active tenant — \
364                     run SET TENANT '<id>' first or name path '{}.{}' explicitly",
365                    query.table, root, tail
366                )));
367            };
368            // Create a fresh JSON column with only the tenant path set.
369            augmented.columns.push(root.to_string());
370            let fresh = merge_dotted_tenant(Value::Null, tail, tenant_id)?;
371            for row in augmented.values.iter_mut() {
372                row.push(fresh.clone());
373            }
374            for row in augmented.value_exprs.iter_mut() {
375                row.push(crate::storage::query::ast::Expr::Literal {
376                    value: fresh.clone(),
377                    span: crate::storage::query::ast::Span::synthetic(),
378                });
379            }
380        }
381
382        Ok(Some(augmented))
383    }
384
385    /// Returns `(affected_count, lsns)`. For the txn (xmax-stamp) path,
386    /// `lsns` is empty because events fire at commit time.
387    fn delete_entities_batch(
388        &self,
389        collection: &str,
390        ids: &[EntityId],
391    ) -> RedDBResult<(u64, Vec<u64>)> {
392        if ids.is_empty() {
393            return Ok((0, vec![]));
394        }
395
396        let store = self.db().store();
397        let Some(manager) = store.get_collection(collection) else {
398            return Ok((0, vec![]));
399        };
400
401        let active_xid = self.current_xid();
402        let conn_id = crate::runtime::impl_core::current_connection_id();
403        let mut autocommit_xid = None;
404        let mut tombstoned_ids = Vec::new();
405        let mut tombstoned_entities = Vec::new();
406        let mut physical_delete_ids = Vec::new();
407        let table_row_resolver =
408            crate::runtime::table_row_mvcc_resolver::TableRowMvccReadResolver::current_statement();
409        // Phase 3: versioned graph collections tombstone node/edge
410        // deletes (xmax stamp) instead of physically removing them, so
411        // `AS OF` time-travel can still resolve the pre-delete version.
412        // Non-versioned graph collections keep the legacy physical
413        // delete (no history). Row entities (Phase 1/2) always tombstone
414        // under universal MVCC and are unaffected by this flag.
415        //
416        // Vectors (Phase 3b): versioned vector deletes tombstone (xmax
417        // stamp) instead of physically dropping, so history is retained.
418        // The `VECTOR SEARCH` read path post-filters every candidate
419        // through the current-snapshot visibility gate (which hides
420        // `xmax != 0` even in autocommit), so a tombstoned versioned
421        // vector never reaches live search. Non-versioned vectors keep
422        // the legacy physical delete.
423        let versioned_collection = self.vcs_is_versioned(collection).unwrap_or(false);
424
425        for &id in ids {
426            let Some(mut entity) = manager.get(id) else {
427                continue;
428            };
429            let is_versioned_graph = versioned_collection
430                && matches!(entity.data, EntityData::Node(_) | EntityData::Edge(_));
431            let is_versioned_vector =
432                versioned_collection && matches!(entity.data, EntityData::Vector(_));
433            if matches!(entity.data, EntityData::Row(_))
434                || is_versioned_graph
435                || is_versioned_vector
436            {
437                let previous_xmax = entity.xmax;
438                if matches!(entity.kind, crate::storage::EntityKind::TableRow { .. }) {
439                    if table_row_resolver.resolve_candidate(&entity).is_none() {
440                        continue;
441                    }
442                } else if is_versioned_vector {
443                    // Versioned vectors gate the delete on the statement
444                    // snapshot (not a crude `xmax != 0`) so a concurrent
445                    // delete whose snapshot predates a still-uncommitted
446                    // tombstone still targets the row, records its own
447                    // pending tombstone, and conflicts at commit
448                    // (first-committer-wins).
449                    if table_row_resolver.resolve_read_candidate(&entity).is_none() {
450                        continue;
451                    }
452                } else if entity.xmax != 0 {
453                    continue;
454                }
455
456                let xid = match active_xid {
457                    Some(xid) => xid,
458                    None => match autocommit_xid {
459                        Some(xid) => xid,
460                        None => {
461                            let mgr = self.snapshot_manager();
462                            let xid = mgr.begin();
463                            autocommit_xid = Some(xid);
464                            xid
465                        }
466                    },
467                };
468                entity.set_xmax(xid);
469                if manager.update(entity.clone()).is_ok() {
470                    if active_xid.is_some() {
471                        self.record_pending_tombstone(conn_id, collection, id, xid, previous_xmax);
472                    }
473                    tombstoned_entities.push(entity);
474                    tombstoned_ids.push(id);
475                }
476            } else {
477                physical_delete_ids.push(id);
478            }
479        }
480
481        if let Some(xid) = autocommit_xid {
482            self.snapshot_manager().commit(xid);
483        }
484
485        let mut affected = tombstoned_ids.len() as u64;
486        let mut lsns = Vec::with_capacity(tombstoned_ids.len() + physical_delete_ids.len());
487        if active_xid.is_some() {
488            store
489                .persist_entities_to_pager(collection, &tombstoned_entities)
490                .map_err(|err| RedDBError::Internal(err.to_string()))?;
491        } else {
492            store
493                .persist_entities_to_pager(collection, &tombstoned_entities)
494                .map_err(|err| RedDBError::Internal(err.to_string()))?;
495            for id in &tombstoned_ids {
496                store.context_index().remove_entity(*id);
497                let lsn = self.cdc_emit(
498                    crate::replication::cdc::ChangeOperation::Delete,
499                    collection,
500                    id.raw(),
501                    "entity",
502                );
503                lsns.push(lsn);
504            }
505        }
506
507        let deleted_ids = store
508            .delete_batch(collection, &physical_delete_ids)
509            .map_err(|err| RedDBError::Internal(err.to_string()))?;
510        affected += deleted_ids.len() as u64;
511        for id in &deleted_ids {
512            store.context_index().remove_entity(*id);
513            let lsn = self.cdc_emit(
514                crate::replication::cdc::ChangeOperation::Delete,
515                collection,
516                id.raw(),
517                "entity",
518            );
519            lsns.push(lsn);
520        }
521
522        Ok((affected, lsns))
523    }
524
525    /// Flushes context-index updates and CDC for each applied mutation.
526    /// Returns one LSN per entity in the same order as `applied`.
527    fn flush_update_chunk(&self, applied: &[AppliedEntityMutation]) -> RedDBResult<Vec<u64>> {
528        if applied.is_empty() {
529            return Ok(Vec::new());
530        }
531
532        let store = self.db().store();
533        if applied.iter().any(|item| item.context_index_dirty) {
534            store.context_index().index_entities(
535                &applied[0].collection,
536                applied
537                    .iter()
538                    .filter(|item| item.context_index_dirty)
539                    .map(|item| &item.entity),
540            );
541        }
542
543        for item in applied {
544            self.refresh_update_secondary_indexes(item)?;
545        }
546
547        let mut lsns = Vec::with_capacity(applied.len());
548        for item in applied {
549            let lsn = self.cdc_emit_prebuilt(
550                crate::replication::cdc::ChangeOperation::Update,
551                &item.collection,
552                &item.entity,
553                update_cdc_item_kind(self, &item.collection, &item.entity),
554                item.metadata.as_ref(),
555                false,
556            );
557            lsns.push(lsn);
558        }
559        Ok(lsns)
560    }
561
562    fn persist_update_chunk(&self, applied: &[AppliedEntityMutation]) -> RedDBResult<()> {
563        self.persist_applied_entity_mutations(applied)
564    }
565
566    fn refresh_update_secondary_indexes(&self, applied: &AppliedEntityMutation) -> RedDBResult<()> {
567        if applied.pre_mutation_fields.is_empty() {
568            return Ok(());
569        }
570        let post = entity_row_fields_snapshot(&applied.entity);
571        if post.is_empty() {
572            return Ok(());
573        }
574
575        // Use the parent-expanded set so that dot-path indexes (e.g.
576        // "body.service.tier") are triggered when the root field ("body")
577        // is modified.
578        let indexed_cols = self
579            .index_store_ref()
580            .indexed_columns_set_with_parents(&applied.collection);
581        if indexed_cols.is_empty() {
582            return Ok(());
583        }
584
585        // Single-source documents keep promoted index columns (`score`) in the
586        // body, not as stored fields. Resolve each indexed column from the body
587        // and fold it into the field snapshots so the diff below sees the value
588        // move — for an ordinary stored column this adds nothing.
589        let pre = self
590            .index_store_ref()
591            .augment_body_derived_index_fields(&applied.pre_mutation_fields, &indexed_cols);
592        let post = self
593            .index_store_ref()
594            .augment_body_derived_index_fields(&post, &indexed_cols);
595
596        if let Some(old_version) = applied.replaced_entity.as_ref() {
597            let old_index_fields: Vec<(String, crate::storage::schema::Value)> = pre
598                .iter()
599                .filter(|(col, _)| indexed_cols.contains(col))
600                .cloned()
601                .collect();
602            let new_index_fields: Vec<(String, crate::storage::schema::Value)> = post
603                .iter()
604                .filter(|(col, _)| indexed_cols.contains(col))
605                .cloned()
606                .collect();
607            if !old_index_fields.is_empty() {
608                self.index_store_ref()
609                    .index_entity_delete(&applied.collection, old_version.id, &old_index_fields)
610                    .map_err(crate::RedDBError::Internal)?;
611            }
612            if !new_index_fields.is_empty() {
613                self.index_store_ref()
614                    .index_entity_insert(&applied.collection, applied.entity.id, &new_index_fields)
615                    .map_err(crate::RedDBError::Internal)?;
616            }
617            return Ok(());
618        }
619
620        let damage = crate::application::entity::row_damage_vector(&pre, &post);
621        if damage
622            .touched_columns()
623            .into_iter()
624            .any(|col| indexed_cols.contains(col))
625        {
626            self.index_store_ref()
627                .index_entity_update(&applied.collection, applied.id, &pre, &post)
628                .map_err(crate::RedDBError::Internal)?;
629        }
630        Ok(())
631    }
632
633    /// Execute INSERT INTO table [entity_type] (cols) VALUES (vals), ...
634    ///
635    /// Each row in `query.values` is zipped with `query.columns` to produce a
636    /// set of named fields, which is then dispatched based on entity_type.
637    pub fn execute_insert(
638        &self,
639        raw_query: &str,
640        query: &InsertQuery,
641    ) -> RedDBResult<RuntimeQueryResult> {
642        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
643        // CollectionContract gate (#49): single entry point for the
644        // operator's collection-level write rules. Today this is a
645        // no-op for INSERT (APPEND ONLY permits insert); routing
646        // through the gate now means future contract bits — versioned,
647        // vault-only writes — plug in once instead of per verb.
648        crate::runtime::collection_contract::CollectionContractGate::check(
649            self,
650            &query.table,
651            crate::runtime::collection_contract::MutationKind::Insert,
652        )?;
653        // Phase 2.5.4 table-scoped tenancy: if the target table is
654        // tenant-scoped and the user didn't name the tenant column,
655        // auto-inject it with the thread-local `CURRENT_TENANT()`
656        // value. When the column is named explicitly we trust the
657        // caller (useful for admin tooling that writes on behalf of
658        // specific tenants). An unbound tenant on an implicit-fill
659        // path errors up front rather than producing a row the RLS
660        // policy would silently hide.
661        let augmented_owned;
662        let query = match self.maybe_inject_tenant_column(query)? {
663            Some(new_q) => {
664                augmented_owned = new_q;
665                &augmented_owned
666            }
667            None => query,
668        };
669        self.check_insert_column_policy(query)?;
670        if let Some(ref embed_config) = query.auto_embed {
671            let provider = crate::ai::parse_provider(&embed_config.provider)?;
672            // S3 / #711: planner-level provider gate. Runs before the
673            // local-model preflight and the API-key resolver so neither
674            // side-effect fires when policy denies.
675            crate::runtime::ai::provider_gate::enforce(self, &provider)?;
676            if matches!(provider, crate::ai::AiProvider::Local) {
677                crate::runtime::ai::local_embedding::ensure_local_embedding_available()?;
678                // Issue #682 — pre-flight the local model registry before
679                // any row write. Missing model, uninstalled artifacts,
680                // wrong task, and disabled-feature failures surface as
681                // deterministic errors that leave the target collection
682                // untouched, satisfying the "no partial writes on
683                // embedding failure" criterion for the failure modes
684                // owned by the local provider.
685                let model_name = embed_config.model.as_deref().map(str::trim).unwrap_or("");
686                if model_name.is_empty() {
687                    return Err(RedDBError::Query(
688                        "AUTO EMBED with provider=local requires MODEL '<registered-model-name>'; \
689                         the local provider does not have an implicit default model"
690                            .to_string(),
691                    ));
692                }
693                crate::runtime::ai::local_embedding::preflight_local_embedding(
694                    &self.inner.db,
695                    model_name,
696                )?;
697            }
698        }
699
700        let mut inserted_count: u64 = 0;
701        let effective_rows =
702            effective_insert_rows(query).map_err(|msg| RedDBError::Query(msg.to_string()))?;
703
704        // Ensure the collection exists (auto-create on first insert).
705        let store = self.inner.db.store();
706        let _ = store.get_or_create_collection(&query.table);
707        let declared_model = self
708            .db()
709            .collection_contract_arc(&query.table)
710            .map(|contract| contract.declared_model);
711
712        let mut returning_snapshots: Option<Vec<Vec<(String, Value)>>> =
713            if query.returning.is_some() {
714                Some(Vec::with_capacity(effective_rows.len()))
715            } else {
716                None
717            };
718        let mut returning_result: Option<UnifiedResult> = None;
719
720        if matches!(query.entity_type, InsertEntityType::Row)
721            && !matches!(
722                declared_model,
723                Some(crate::catalog::CollectionModel::TimeSeries)
724            )
725        {
726            // Issue #523 + #524: blockchain collections seal each row into the
727            // chain. When the caller omits the reserved columns, the engine
728            // auto-fills (#523). When the caller supplies any reserved column,
729            // the values are validated against the current tip and a mismatch
730            // surfaces a `BlockchainConflict:` error mapped to HTTP 409 (#524).
731            //
732            // The whole batch runs under a per-collection chain lock so two
733            // concurrent submitters can't both bind to the same prev_hash —
734            // the loser observes the advanced tip and gets 409 with the new
735            // tip so it can retry.
736            let chain_mode = crate::runtime::blockchain_kind::is_chain(&store, &query.table);
737            let _chain_lock_arc: Option<Arc<parking_lot::Mutex<()>>> = if chain_mode {
738                Some(self.inner.rmw_locks.lock_for(&query.table, "__chain__"))
739            } else {
740                None
741            };
742            let _chain_guard = _chain_lock_arc.as_ref().map(|m| m.lock());
743
744            // Issue #525 — refuse new blocks if the chain has been marked
745            // `integrity = broken` until an admin clears the flag.
746            if chain_mode && self.is_chain_integrity_broken(&query.table) {
747                return Err(RedDBError::InvalidOperation(format!(
748                    "ChainIntegrityBroken: collection '{}' is locked until \
749                     POST /collections/{}/clear-integrity-flag is called by an admin",
750                    query.table, query.table
751                )));
752            }
753
754            // Pull the tip from the in-memory cache; fall back to a one-time
755            // scan if the cache hasn't seen this collection yet (cold start
756            // after restart). Cache is updated below as rows are sealed.
757            let mut chain_tip_full: Option<crate::runtime::blockchain_kind::ChainTipFull> =
758                if chain_mode {
759                    let mut cache = self.inner.chain_tip_cache.lock();
760                    if let Some(existing) = cache.get(&query.table) {
761                        Some(existing.clone())
762                    } else if let Some(scanned) =
763                        crate::runtime::blockchain_kind::chain_tip_full(&store, &query.table)
764                    {
765                        cache.insert(query.table.clone(), scanned.clone());
766                        Some(scanned)
767                    } else {
768                        None
769                    }
770                } else {
771                    None
772                };
773
774            let mut rows = Vec::with_capacity(effective_rows.len());
775            for row_values in &effective_rows {
776                if row_values.len() != query.columns.len() {
777                    return Err(RedDBError::Query(format!(
778                        "INSERT column count ({}) does not match value count ({})",
779                        query.columns.len(),
780                        row_values.len()
781                    )));
782                }
783                let (mut fields, mut metadata) =
784                    split_insert_metadata(self, &query.columns, row_values)?;
785                if chain_mode {
786                    use crate::runtime::blockchain_kind::{
787                        chain_conflict_error, COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH,
788                        COL_TIMESTAMP, RESERVED_COLUMNS,
789                    };
790                    let supplied_height = fields
791                        .iter()
792                        .find(|(k, _)| k == COL_BLOCK_HEIGHT)
793                        .map(|(_, v)| v.clone());
794                    let supplied_prev = fields
795                        .iter()
796                        .find(|(k, _)| k == COL_PREV_HASH)
797                        .map(|(_, v)| v.clone());
798                    let supplied_ts = fields
799                        .iter()
800                        .find(|(k, _)| k == COL_TIMESTAMP)
801                        .map(|(_, v)| v.clone());
802                    let supplied_hash = fields.iter().any(|(k, _)| k == COL_HASH);
803                    let user_supplied_any = supplied_height.is_some()
804                        || supplied_prev.is_some()
805                        || supplied_ts.is_some()
806                        || supplied_hash;
807
808                    fields.retain(|(k, _)| !RESERVED_COLUMNS.contains(&k.as_str()));
809                    let payload = crate::runtime::blockchain_kind::canonical_payload(&fields);
810
811                    let (tip_prev_hash, tip_next_height) = match &chain_tip_full {
812                        Some(t) => (t.hash, t.height + 1),
813                        None => (crate::storage::blockchain::GENESIS_PREV_HASH, 0u64),
814                    };
815                    let server_now = crate::runtime::blockchain_kind::now_ms();
816
817                    let (use_prev, use_height, use_ts) = if user_supplied_any {
818                        // Caller is participating in the chain protocol —
819                        // every field must be supplied AND match the tip.
820                        if supplied_hash {
821                            return Err(chain_conflict_error(
822                                tip_next_height.saturating_sub(1),
823                                tip_prev_hash,
824                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
825                                server_now,
826                                "hash column is engine-computed and cannot be supplied",
827                            ));
828                        }
829                        let caller_prev = match &supplied_prev {
830                            Some(Value::Blob(b)) if b.len() == 32 => {
831                                let mut a = [0u8; 32];
832                                a.copy_from_slice(b);
833                                a
834                            }
835                            Some(Value::Text(s)) if s.len() == 64 => {
836                                // Accept hex-encoded prev_hash so JSON / SQL
837                                // callers without literal-blob syntax can
838                                // still participate in the chain protocol.
839                                let mut a = [0u8; 32];
840                                let mut ok = true;
841                                for (i, slot) in a.iter_mut().enumerate() {
842                                    let pair = &s.as_ref()[i * 2..i * 2 + 2];
843                                    match u8::from_str_radix(pair, 16) {
844                                        Ok(byte) => *slot = byte,
845                                        Err(_) => {
846                                            ok = false;
847                                            break;
848                                        }
849                                    }
850                                }
851                                if !ok {
852                                    return Err(chain_conflict_error(
853                                        tip_next_height.saturating_sub(1),
854                                        tip_prev_hash,
855                                        chain_tip_full
856                                            .as_ref()
857                                            .map(|t| t.timestamp_ms)
858                                            .unwrap_or(0),
859                                        server_now,
860                                        "prev_hash is not valid hex",
861                                    ));
862                                }
863                                a
864                            }
865                            _ => {
866                                return Err(chain_conflict_error(
867                                    tip_next_height.saturating_sub(1),
868                                    tip_prev_hash,
869                                    chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
870                                    server_now,
871                                    "prev_hash missing or not a 32-byte Blob",
872                                ));
873                            }
874                        };
875                        if caller_prev != tip_prev_hash {
876                            return Err(chain_conflict_error(
877                                tip_next_height.saturating_sub(1),
878                                tip_prev_hash,
879                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
880                                server_now,
881                                "prev_hash does not match current tip",
882                            ));
883                        }
884                        let caller_height = match &supplied_height {
885                            Some(Value::UnsignedInteger(v)) => *v,
886                            Some(Value::Integer(v)) if *v >= 0 => *v as u64,
887                            _ => {
888                                return Err(chain_conflict_error(
889                                    tip_next_height.saturating_sub(1),
890                                    tip_prev_hash,
891                                    chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
892                                    server_now,
893                                    "block_height missing or not an unsigned integer",
894                                ));
895                            }
896                        };
897                        if caller_height != tip_next_height {
898                            return Err(chain_conflict_error(
899                                tip_next_height.saturating_sub(1),
900                                tip_prev_hash,
901                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
902                                server_now,
903                                "block_height does not match tip+1",
904                            ));
905                        }
906                        let caller_ts = match &supplied_ts {
907                            Some(Value::UnsignedInteger(v)) => *v,
908                            Some(Value::Integer(v)) if *v >= 0 => *v as u64,
909                            _ => {
910                                return Err(chain_conflict_error(
911                                    tip_next_height.saturating_sub(1),
912                                    tip_prev_hash,
913                                    chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
914                                    server_now,
915                                    "timestamp missing or not an unsigned integer",
916                                ));
917                            }
918                        };
919                        let drift = (caller_ts as i128) - (server_now as i128);
920                        if drift.abs() > 60_000 {
921                            return Err(chain_conflict_error(
922                                tip_next_height.saturating_sub(1),
923                                tip_prev_hash,
924                                chain_tip_full.as_ref().map(|t| t.timestamp_ms).unwrap_or(0),
925                                server_now,
926                                "timestamp outside ±60s of server_time",
927                            ));
928                        }
929                        (caller_prev, caller_height, caller_ts)
930                    } else {
931                        (tip_prev_hash, tip_next_height, server_now)
932                    };
933
934                    let (reserved, new_hash) =
935                        crate::runtime::blockchain_kind::make_block_reserved_fields(
936                            use_prev, use_height, use_ts, &payload,
937                        );
938                    fields.extend(reserved);
939                    chain_tip_full = Some(crate::runtime::blockchain_kind::ChainTipFull {
940                        height: use_height,
941                        hash: new_hash,
942                        timestamp_ms: use_ts,
943                    });
944                }
945                // Issue #522 — signed-writes verification. On collections
946                // created with `SIGNED_BY (...)` the row must carry valid
947                // `signer_pubkey` + `signature` reserved columns. Runs
948                // after chain_mode so canonical payload covers user-supplied
949                // fields only (blockchain reserved columns are filtered by
950                // `canonical_payload`; the two signed-writes reserved
951                // columns are split out before payload computation, then
952                // re-attached for storage). The blockchain + SIGNED_BY
953                // composition is owned by issue #526; we keep #522 to the
954                // non-chain path and let chain_mode collections punt to that
955                // slice rather than half-wire it here.
956                if crate::runtime::signed_writes_kind::is_signed(&store, &query.table) {
957                    let (pk_col, sig_col, residual) =
958                        crate::runtime::signed_writes_kind::split_signature_fields(fields);
959                    let payload = crate::runtime::blockchain_kind::canonical_payload(&residual);
960                    let reg = crate::runtime::signed_writes_kind::registry(&store, &query.table);
961                    crate::runtime::signed_writes_kind::verify_row(
962                        &reg,
963                        pk_col.as_ref().map(|c| c.bytes.as_slice()),
964                        sig_col.as_ref().map(|c| c.bytes.as_slice()),
965                        &payload,
966                    )
967                    .map_err(crate::runtime::signed_writes_kind::map_error)?;
968                    fields = residual;
969                    // Round-trip the reserved columns with the value
970                    // type the caller supplied (Text/hex on the SQL path,
971                    // Blob on the binary path). Keeps SELECT and WHERE
972                    // predicates symmetric with the INSERT shape.
973                    if let Some(col) = pk_col {
974                        fields.push((
975                            crate::storage::signed_writes::RESERVED_SIGNER_PUBKEY_COL.to_string(),
976                            col.raw_value,
977                        ));
978                    }
979                    if let Some(col) = sig_col {
980                        fields.push((
981                            crate::storage::signed_writes::RESERVED_SIGNATURE_COL.to_string(),
982                            col.raw_value,
983                        ));
984                    }
985                }
986                merge_with_clauses(
987                    &mut metadata,
988                    query.ttl_ms,
989                    query.expires_at_ms,
990                    &query.with_metadata,
991                );
992                if let Some(snaps) = returning_snapshots.as_mut() {
993                    snaps.push(fields.clone());
994                }
995                rows.push(CreateRowInput {
996                    collection: query.table.clone(),
997                    fields,
998                    metadata,
999                    node_links: Vec::new(),
1000                    vector_links: Vec::new(),
1001                });
1002            }
1003            let outputs = self.create_rows_batch(CreateRowsBatchInput {
1004                collection: query.table.clone(),
1005                rows,
1006                suppress_events: query.suppress_events,
1007            })?;
1008            inserted_count = outputs.len() as u64;
1009
1010            // Chain mode: commit the new tip to the in-memory cache only after
1011            // the batch persisted successfully. If the batch threw mid-way the
1012            // cache stays on the previous tip and the chain lock releases.
1013            if chain_mode {
1014                if let Some(new_tip) = chain_tip_full.as_ref() {
1015                    self.inner
1016                        .chain_tip_cache
1017                        .lock()
1018                        .insert(query.table.clone(), new_tip.clone());
1019                }
1020            }
1021
1022            // Hypertable chunk routing: if this table was declared via
1023            // CREATE HYPERTABLE, register each row's time-column value
1024            // with the registry so chunk metadata (bounds, row counts,
1025            // TTL eligibility) stays current. This is what lets
1026            // HYPERTABLE_PRUNE_CHUNKS answer real questions + lets the
1027            // retention daemon sweep expired chunks without scanning
1028            // every row.
1029            if let Some(spec) = self.inner.db.hypertables().get(&query.table) {
1030                let time_col = &spec.time_column;
1031                // Find the column's index in the INSERT column list.
1032                if let Some(idx) = query.columns.iter().position(|c| c == time_col) {
1033                    for row in &effective_rows {
1034                        if let Some(Value::Integer(n) | Value::BigInt(n)) = row.get(idx) {
1035                            if *n >= 0 {
1036                                let _ = self.inner.db.hypertables().route(&query.table, *n as u64);
1037                            }
1038                        } else if let Some(Value::UnsignedInteger(n)) = row.get(idx) {
1039                            let _ = self.inner.db.hypertables().route(&query.table, *n);
1040                        }
1041                    }
1042                }
1043            }
1044
1045            if let (Some(items), Some(snaps)) =
1046                (query.returning.as_ref(), returning_snapshots.take())
1047            {
1048                let snaps = row_insert_returning_snapshots(&outputs, snaps);
1049                returning_result = Some(build_returning_result(items, &snaps, Some(&outputs)));
1050            }
1051        } else {
1052            // Issue #419: surface the inserted entity id on every INSERT path.
1053            // For Node/Edge/Vector/Document/Kv we now keep each CreateEntityOutput
1054            // so a RETURNING clause (and the unconditional inserted_ids list,
1055            // below) can expose the engine-assigned id. TimeSeries (the row
1056            // branch in this else) still returns the not-supported error
1057            // because create_timeseries_point isn't plumbed through this fn.
1058            let mut entity_outputs: Vec<crate::application::entity::CreateEntityOutput> =
1059                Vec::with_capacity(effective_rows.len());
1060            let mut returning_field_snaps: Vec<Vec<(String, Value)>> = if query.returning.is_some()
1061            {
1062                Vec::with_capacity(effective_rows.len())
1063            } else {
1064                Vec::new()
1065            };
1066            if matches!(
1067                query.entity_type,
1068                InsertEntityType::Node | InsertEntityType::Edge
1069            ) {
1070                enum PreparedGraphInsert {
1071                    Node {
1072                        fields: Vec<(String, Value)>,
1073                        input: CreateNodeInput,
1074                    },
1075                    Edge {
1076                        fields: Vec<(String, Value)>,
1077                        input: CreateEdgeInput,
1078                    },
1079                }
1080
1081                let mut prepared = Vec::with_capacity(effective_rows.len());
1082                for row_values in &effective_rows {
1083                    if row_values.len() != query.columns.len() {
1084                        return Err(RedDBError::Query(format!(
1085                            "INSERT column count ({}) does not match value count ({})",
1086                            query.columns.len(),
1087                            row_values.len()
1088                        )));
1089                    }
1090
1091                    match query.entity_type {
1092                        InsertEntityType::Node => {
1093                            let (node_values, mut metadata) =
1094                                split_insert_metadata(self, &query.columns, row_values)?;
1095                            merge_with_clauses(
1096                                &mut metadata,
1097                                query.ttl_ms,
1098                                query.expires_at_ms,
1099                                &query.with_metadata,
1100                            );
1101                            ensure_non_tree_reserved_metadata_entries(&metadata)?;
1102                            apply_collection_default_ttl_metadata(
1103                                self,
1104                                &query.table,
1105                                &mut metadata,
1106                            );
1107                            let (columns, values) = pairwise_columns_values(&node_values);
1108                            let label = find_column_value_string(&columns, &values, "label")?;
1109                            let node_type =
1110                                find_column_value_opt_string(&columns, &values, "node_type");
1111                            let properties = extract_remaining_properties(
1112                                &columns,
1113                                &values,
1114                                &["label", "node_type"],
1115                            );
1116                            crate::reserved_fields::ensure_no_reserved_public_item_fields(
1117                                properties.iter().map(|(key, _)| key.as_str()),
1118                                &format!("node '{}'", query.table),
1119                            )?;
1120                            prepared.push(PreparedGraphInsert::Node {
1121                                fields: node_values,
1122                                input: CreateNodeInput {
1123                                    collection: query.table.clone(),
1124                                    label,
1125                                    node_type,
1126                                    properties,
1127                                    metadata,
1128                                    embeddings: Vec::new(),
1129                                    table_links: Vec::new(),
1130                                    node_links: Vec::new(),
1131                                },
1132                            });
1133                        }
1134                        InsertEntityType::Edge => {
1135                            let (edge_values, mut metadata) =
1136                                split_insert_metadata(self, &query.columns, row_values)?;
1137                            merge_with_clauses(
1138                                &mut metadata,
1139                                query.ttl_ms,
1140                                query.expires_at_ms,
1141                                &query.with_metadata,
1142                            );
1143                            ensure_non_tree_reserved_metadata_entries(&metadata)?;
1144                            apply_collection_default_ttl_metadata(
1145                                self,
1146                                &query.table,
1147                                &mut metadata,
1148                            );
1149                            let (columns, values) = pairwise_columns_values(&edge_values);
1150                            let label = find_column_value_string(&columns, &values, "label")?;
1151                            ensure_non_tree_structural_edge_label(&label)?;
1152                            let from_id = resolve_edge_endpoint_any(
1153                                self.inner.db.store().as_ref(),
1154                                &query.table,
1155                                &columns,
1156                                &values,
1157                                &["from_rid", "from"],
1158                            )?;
1159                            let to_id = resolve_edge_endpoint_any(
1160                                self.inner.db.store().as_ref(),
1161                                &query.table,
1162                                &columns,
1163                                &values,
1164                                &["to_rid", "to"],
1165                            )?;
1166                            let weight = find_column_value_f32_opt(&columns, &values, "weight");
1167                            let properties = extract_remaining_properties(
1168                                &columns,
1169                                &values,
1170                                &["label", "from_rid", "to_rid", "from", "to", "weight"],
1171                            );
1172                            crate::reserved_fields::ensure_no_reserved_public_item_fields(
1173                                properties.iter().map(|(key, _)| key.as_str()),
1174                                &format!("edge '{}'", query.table),
1175                            )?;
1176                            prepared.push(PreparedGraphInsert::Edge {
1177                                fields: edge_values,
1178                                input: CreateEdgeInput {
1179                                    collection: query.table.clone(),
1180                                    label,
1181                                    from: EntityId::new(from_id),
1182                                    to: EntityId::new(to_id),
1183                                    weight,
1184                                    properties,
1185                                    metadata,
1186                                },
1187                            });
1188                        }
1189                        _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1190                    }
1191                }
1192
1193                ensure_graph_insert_contract(self, &query.table)?;
1194                let mut batch = self.inner.db.batch();
1195                for item in prepared {
1196                    match item {
1197                        PreparedGraphInsert::Node { fields, input } => {
1198                            if query.returning.is_some() {
1199                                returning_field_snaps.push(fields);
1200                            }
1201                            let node_type = input.node_type.unwrap_or_else(|| input.label.clone());
1202                            batch = batch.add_node_with_type(
1203                                input.collection,
1204                                input.label,
1205                                node_type,
1206                                input.properties.into_iter().collect(),
1207                                input.metadata.into_iter().collect(),
1208                            );
1209                        }
1210                        PreparedGraphInsert::Edge { fields, input } => {
1211                            if query.returning.is_some() {
1212                                returning_field_snaps.push(fields);
1213                            }
1214                            batch = batch.add_edge(
1215                                input.collection,
1216                                input.label,
1217                                input.from,
1218                                input.to,
1219                                input.weight.unwrap_or(1.0),
1220                                input.properties.into_iter().collect(),
1221                                input.metadata.into_iter().collect(),
1222                            );
1223                        }
1224                    }
1225                }
1226                let batch_result = batch
1227                    .execute()
1228                    .map_err(|err| RedDBError::Internal(format!("{err:?}")))?;
1229                let (ids, entity_kind) = match query.entity_type {
1230                    InsertEntityType::Node => (batch_result.nodes, "graph_node"),
1231                    InsertEntityType::Edge => (batch_result.edges, "graph_edge"),
1232                    _ => unreachable!("prepared graph insert only handles NODE and EDGE"),
1233                };
1234                for id in &ids {
1235                    self.stamp_xmin_if_in_txn(&query.table, *id);
1236                }
1237                if query.returning.is_some() {
1238                    returning_field_snaps = graph_insert_returning_snapshots(
1239                        self.inner.db.store().as_ref(),
1240                        &query.table,
1241                        &ids,
1242                    );
1243                }
1244                self.cdc_emit_insert_batch_no_cache_invalidate(&query.table, &ids, entity_kind);
1245                let store = self.inner.db.store();
1246                entity_outputs.extend(ids.iter().map(|id| {
1247                    crate::application::entity::CreateEntityOutput {
1248                        id: *id,
1249                        entity: store.get(&query.table, *id),
1250                    }
1251                }));
1252                inserted_count = ids.len() as u64;
1253            } else {
1254                for row_values in &effective_rows {
1255                    if row_values.len() != query.columns.len() {
1256                        return Err(RedDBError::Query(format!(
1257                            "INSERT column count ({}) does not match value count ({})",
1258                            query.columns.len(),
1259                            row_values.len()
1260                        )));
1261                    }
1262
1263                    match query.entity_type {
1264                        InsertEntityType::Row => {
1265                            if query.returning.is_some() {
1266                                return Err(RedDBError::Query(
1267                                "RETURNING is not yet supported for this INSERT path (TimeSeries)"
1268                                    .to_string(),
1269                            ));
1270                            }
1271                            let (fields, mut metadata) =
1272                                split_insert_metadata(self, &query.columns, row_values)?;
1273                            merge_with_clauses(
1274                                &mut metadata,
1275                                query.ttl_ms,
1276                                query.expires_at_ms,
1277                                &query.with_metadata,
1278                            );
1279                            self.insert_timeseries_point(&query.table, fields, metadata)?;
1280                        }
1281                        InsertEntityType::Node | InsertEntityType::Edge => {
1282                            unreachable!("NODE and EDGE are handled by the prepared graph path")
1283                        }
1284                        InsertEntityType::Vector => {
1285                            let (vector_values, mut metadata) =
1286                                split_insert_metadata(self, &query.columns, row_values)?;
1287                            merge_with_clauses(
1288                                &mut metadata,
1289                                query.ttl_ms,
1290                                query.expires_at_ms,
1291                                &query.with_metadata,
1292                            );
1293                            let (columns, values) = pairwise_columns_values(&vector_values);
1294                            let dense = find_column_value_vec_f32_any(
1295                                &columns,
1296                                &values,
1297                                &["dense", "embedding"],
1298                            )?;
1299                            merge_vector_metadata_column(&mut metadata, &columns, &values)?;
1300                            let content =
1301                                find_column_value_opt_string(&columns, &values, "content");
1302                            if query.returning.is_some() {
1303                                returning_field_snaps.push(vector_values.clone());
1304                            }
1305                            let input = CreateVectorInput {
1306                                collection: query.table.clone(),
1307                                dense,
1308                                content,
1309                                metadata,
1310                                link_row: None,
1311                                link_node: None,
1312                            };
1313                            entity_outputs.push(self.create_vector(input)?);
1314                        }
1315                        InsertEntityType::Document => {
1316                            let (document_values, mut metadata) =
1317                                split_insert_metadata(self, &query.columns, row_values)?;
1318                            merge_with_clauses(
1319                                &mut metadata,
1320                                query.ttl_ms,
1321                                query.expires_at_ms,
1322                                &query.with_metadata,
1323                            );
1324                            let (columns, values) = pairwise_columns_values(&document_values);
1325                            let body = find_document_body_json(&columns, &values)?;
1326                            let input = CreateDocumentInput {
1327                                collection: query.table.clone(),
1328                                body,
1329                                metadata,
1330                                node_links: Vec::new(),
1331                                vector_links: Vec::new(),
1332                            };
1333                            let output = self.create_document(input)?;
1334                            if query.returning.is_some() {
1335                                let fields = output
1336                                    .entity
1337                                    .as_ref()
1338                                    .map(entity_row_fields_snapshot)
1339                                    .filter(|fields| !fields.is_empty())
1340                                    .unwrap_or(document_values);
1341                                returning_field_snaps.push(fields);
1342                            }
1343                            entity_outputs.push(output);
1344                        }
1345                        InsertEntityType::Kv => {
1346                            let (kv_values, mut metadata) =
1347                                split_insert_metadata(self, &query.columns, row_values)?;
1348                            merge_with_clauses(
1349                                &mut metadata,
1350                                query.ttl_ms,
1351                                query.expires_at_ms,
1352                                &query.with_metadata,
1353                            );
1354                            let (columns, values) = pairwise_columns_values(&kv_values);
1355                            let key = find_column_value_string(&columns, &values, "key")?;
1356                            let value = find_column_value(&columns, &values, "value")?;
1357                            if query.returning.is_some() {
1358                                returning_field_snaps.push(kv_values.clone());
1359                            }
1360                            let input = CreateKvInput {
1361                                collection: query.table.clone(),
1362                                key,
1363                                value,
1364                                metadata,
1365                            };
1366                            entity_outputs.push(self.create_kv(input)?);
1367                        }
1368                    }
1369
1370                    inserted_count += 1;
1371                }
1372            }
1373
1374            if let Some(items) = query.returning.as_ref() {
1375                if !entity_outputs.is_empty() {
1376                    returning_result = Some(build_returning_result(
1377                        items,
1378                        &returning_field_snaps,
1379                        Some(&entity_outputs),
1380                    ));
1381                }
1382            }
1383        }
1384
1385        // Auto-embed pipeline: batch-embed fields across all inserted rows via AiBatchClient.
1386        if let Some(ref embed_config) = query.auto_embed {
1387            let store = self.inner.db.store();
1388            let provider = crate::ai::parse_provider(&embed_config.provider)?;
1389            let is_local_provider = matches!(provider, crate::ai::AiProvider::Local);
1390            // Local provider runs in-process — no API key path applies.
1391            // The pre-flight above already required `MODEL '<name>'`
1392            // for the local case, so the unwrap_or default below only
1393            // ever fires for OpenAI-compatible providers.
1394            let api_key = if is_local_provider {
1395                String::new()
1396            } else {
1397                crate::ai::resolve_api_key_from_runtime(&provider, None, self)?
1398            };
1399            let model = embed_config.model.clone().unwrap_or_else(|| {
1400                std::env::var("REDDB_OPENAI_EMBEDDING_MODEL")
1401                    .ok()
1402                    .unwrap_or_else(|| crate::ai::DEFAULT_OPENAI_EMBEDDING_MODEL.to_string())
1403            });
1404
1405            // Collect the just-inserted rows (most-recently appended, reversed back to insert order).
1406            let manager = store
1407                .get_collection(&query.table)
1408                .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
1409            let entities = manager.query_all(|_| true);
1410            let recent: Vec<_> = entities
1411                .into_iter()
1412                .rev()
1413                .take(effective_rows.len())
1414                .collect();
1415
1416            // Collector phase: (entity_index, combined_text) for rows that have non-empty fields.
1417            let entity_combos: Vec<(usize, String)> = recent
1418                .iter()
1419                .enumerate()
1420                .filter_map(|(i, entity)| {
1421                    if let EntityData::Row(ref row) = entity.data {
1422                        if let Some(ref named) = row.named {
1423                            let texts: Vec<String> = embed_config
1424                                .fields
1425                                .iter()
1426                                .filter_map(|field| match named.get(field) {
1427                                    Some(Value::Text(t)) if !t.is_empty() => Some(t.to_string()),
1428                                    _ => None,
1429                                })
1430                                .collect();
1431                            if !texts.is_empty() {
1432                                return Some((i, texts.join(" ")));
1433                            }
1434                        }
1435                    }
1436                    None
1437                })
1438                .collect();
1439
1440            if !entity_combos.is_empty() {
1441                // Batch phase: single provider round-trip for all rows.
1442                let batch_texts: Vec<String> =
1443                    entity_combos.iter().map(|(_, t)| t.clone()).collect();
1444
1445                // Issue #682 — when the provider is `local`, bypass
1446                // AiBatchClient (which is HTTP-only) and dispatch
1447                // directly through the in-process local embedding
1448                // backend. All texts go in one call, mirroring the
1449                // single-round-trip shape of the remote path. The
1450                // local backend does not perform intra-batch dedup —
1451                // each input position gets its own row in the output
1452                // — which keeps the per-row "create_vector" loop
1453                // below correct without additional fan-out logic.
1454                let embeddings = if is_local_provider {
1455                    let response = crate::runtime::ai::local_embedding::embed_local_with_db(
1456                        &self.inner.db,
1457                        &model,
1458                        batch_texts,
1459                    )?;
1460                    response.embeddings
1461                } else {
1462                    let batch_client =
1463                        crate::runtime::ai::batch_client::AiBatchClient::from_runtime(self);
1464
1465                    match tokio::runtime::Handle::try_current() {
1466                        Ok(handle) => tokio::task::block_in_place(|| {
1467                            handle.block_on(batch_client.embed_batch(
1468                                &provider,
1469                                &model,
1470                                &api_key,
1471                                batch_texts,
1472                            ))
1473                        }),
1474                        Err(_) => {
1475                            return Err(RedDBError::Query(
1476                                "AUTO EMBED requires a Tokio runtime context".to_string(),
1477                            ));
1478                        }
1479                    }
1480                    .map_err(|e| RedDBError::Query(e.to_string()))?
1481                };
1482
1483                // Distribute phase: persist one vector per non-empty embedding.
1484                for ((_, combined), dense) in entity_combos.iter().zip(embeddings) {
1485                    if dense.is_empty() {
1486                        continue;
1487                    }
1488                    self.create_vector(CreateVectorInput {
1489                        collection: query.table.clone(),
1490                        dense,
1491                        content: Some(combined.clone()),
1492                        metadata: Vec::new(),
1493                        link_row: None,
1494                        link_node: None,
1495                    })?;
1496                }
1497            }
1498        }
1499
1500        if inserted_count > 0 {
1501            self.note_table_write(&query.table);
1502        }
1503
1504        let mut result = RuntimeQueryResult::dml_result(
1505            raw_query.to_string(),
1506            inserted_count,
1507            "insert",
1508            "runtime-dml",
1509        );
1510        if let Some(returning) = returning_result {
1511            result.result = returning;
1512        }
1513        Ok(result)
1514    }
1515
1516    fn check_insert_column_policy(&self, query: &InsertQuery) -> RedDBResult<()> {
1517        let Some(auth_store) = self.inner.auth_store.read().clone() else {
1518            return Ok(());
1519        };
1520        if !auth_store.iam_authorization_enabled() {
1521            return Ok(());
1522        }
1523        let Some((username, role)) = crate::runtime::impl_core::current_auth_identity() else {
1524            return Ok(());
1525        };
1526
1527        let tenant = crate::runtime::impl_core::current_tenant();
1528        let principal = crate::auth::UserId::from_parts(tenant.as_deref(), &username);
1529        let request = crate::auth::ColumnAccessRequest {
1530            action: "insert".to_string(),
1531            schema: None,
1532            table: query.table.clone(),
1533            columns: query.columns.clone(),
1534        };
1535        let ctx = crate::auth::policies::EvalContext {
1536            principal_tenant: tenant.clone(),
1537            current_tenant: tenant,
1538            peer_ip: None,
1539            mfa_present: false,
1540            now_ms: crate::auth::now_ms(),
1541            principal_is_admin_role: role == crate::auth::Role::Admin,
1542            principal_is_platform_scoped: principal.tenant.is_none(),
1543        };
1544
1545        let outcome = auth_store.check_column_projection_authz(&principal, &request, &ctx);
1546        let table_allowed = matches!(
1547            outcome.table_decision,
1548            crate::auth::policies::Decision::Allow { .. }
1549                | crate::auth::policies::Decision::AdminBypass
1550        );
1551        if !table_allowed {
1552            return Err(RedDBError::Query(format!(
1553                "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1554                outcome.table_resource.kind, outcome.table_resource.name
1555            )));
1556        }
1557        if let Some(denied) = outcome.first_denied_column() {
1558            return Err(RedDBError::Query(format!(
1559                "principal=`{username}` action=`insert` resource=`{}:{}` denied by IAM policy",
1560                denied.resource.kind, denied.resource.name
1561            )));
1562        }
1563
1564        Ok(())
1565    }
1566
1567    pub(crate) fn insert_timeseries_point(
1568        &self,
1569        collection: &str,
1570        fields: Vec<(String, Value)>,
1571        mut metadata: Vec<(String, MetadataValue)>,
1572    ) -> RedDBResult<EntityId> {
1573        apply_collection_default_ttl_metadata(self, collection, &mut metadata);
1574
1575        let (columns, values) = pairwise_columns_values(&fields);
1576        validate_timeseries_insert_columns(&columns)?;
1577
1578        // Issue #577 — AnalyticsSchemaRegistry hook. If the row carries
1579        // an `event_name` whose schema is registered, validate the
1580        // `payload` JSON against it BEFORE any write side-effect. On
1581        // failure we return a typed error and the row is not
1582        // persisted. When no schema is registered for the event name
1583        // (or no `event_name` column is supplied at all) we fall
1584        // through to the normal write path for back-compat with
1585        // existing timeseries rows.
1586        let event_name_opt = find_column_value_opt_string(&columns, &values, "event_name");
1587        let payload_opt = find_column_value_opt_string(&columns, &values, "payload");
1588        if let Some(event_name) = event_name_opt.as_deref() {
1589            let store_for_schema = self.inner.db.store();
1590            if super::analytics_schema_registry::latest(store_for_schema.as_ref(), event_name)
1591                .is_some()
1592            {
1593                let payload_json = payload_opt.as_deref().unwrap_or("{}");
1594                super::analytics_schema_registry::validate(
1595                    store_for_schema.as_ref(),
1596                    event_name,
1597                    payload_json,
1598                )
1599                .map_err(super::analytics_schema_registry::validation_error_to_reddb)?;
1600            }
1601        }
1602
1603        // `metric` is required by the existing timeseries write path;
1604        // when an analytics-style row supplies `event_name` but not
1605        // `metric`, fall back to the event name so the storage path
1606        // still has a non-empty metric tag.
1607        let metric = match find_column_value_opt_string(&columns, &values, "metric") {
1608            Some(m) => m,
1609            None => event_name_opt.clone().ok_or_else(|| {
1610                RedDBError::Query(
1611                    "timeseries INSERT requires either `metric` or `event_name`".to_string(),
1612                )
1613            })?,
1614        };
1615        // `value` is optional for analytics-event rows (which are
1616        // semantically counts of 1); default to 1.0 when missing so
1617        // analytics inserts don't have to fabricate a metric value.
1618        let value = match find_column_value_opt_string(&columns, &values, "value") {
1619            Some(s) => s.parse::<f64>().unwrap_or(1.0),
1620            None => columns
1621                .iter()
1622                .position(|c| c.eq_ignore_ascii_case("value"))
1623                .and_then(|i| match &values[i] {
1624                    Value::Float(f) => Some(*f),
1625                    Value::Integer(n) | Value::BigInt(n) => Some(*n as f64),
1626                    Value::UnsignedInteger(n) => Some(*n as f64),
1627                    _ => None,
1628                })
1629                .unwrap_or(1.0),
1630        };
1631        let timestamp_ns =
1632            find_timeseries_timestamp_ns(&columns, &values)?.unwrap_or_else(current_unix_ns);
1633        let mut tags = find_timeseries_tags(&columns, &values)?;
1634        if let Some(ref name) = event_name_opt {
1635            tags.entry("event_name".to_string())
1636                .or_insert_with(|| name.clone());
1637        }
1638        if let Some(ref payload) = payload_opt {
1639            tags.entry("payload".to_string())
1640                .or_insert_with(|| payload.clone());
1641        }
1642
1643        let mut entity = UnifiedEntity::new(
1644            EntityId::new(0),
1645            EntityKind::TimeSeriesPoint(Box::new(crate::storage::TimeSeriesPointKind {
1646                series: collection.to_string(),
1647                metric: metric.clone(),
1648            })),
1649            EntityData::TimeSeries(crate::storage::TimeSeriesData {
1650                metric,
1651                timestamp_ns,
1652                value,
1653                tags,
1654            }),
1655        );
1656        // MVCC #30: stamp xmin with the active tx xid (inside a tx)
1657        // or an autocommit xid (allocated and committed up-front so
1658        // future snapshots see the row as soon as it lands).
1659        let writer_xid = match self.current_xid() {
1660            Some(xid) => xid,
1661            None => {
1662                let mgr = self.snapshot_manager();
1663                let xid = mgr.begin();
1664                mgr.commit(xid);
1665                xid
1666            }
1667        };
1668        entity.set_xmin(writer_xid);
1669
1670        let store = self.inner.db.store();
1671        let id = store
1672            .insert_auto(collection, entity)
1673            .map_err(|err| RedDBError::Internal(err.to_string()))?;
1674
1675        if !metadata.is_empty() {
1676            let _ = store.set_metadata(
1677                collection,
1678                id,
1679                Metadata::with_fields(metadata.into_iter().collect()),
1680            );
1681        }
1682
1683        self.cdc_emit(
1684            crate::replication::cdc::ChangeOperation::Insert,
1685            collection,
1686            id.raw(),
1687            "timeseries",
1688        );
1689
1690        Ok(id)
1691    }
1692
1693    /// Execute UPDATE table SET col=val, ... WHERE filter
1694    ///
1695    /// Scans the target collection, evaluates the WHERE filter against each
1696    /// record, and patches every matching entity.
1697    pub fn execute_update(
1698        &self,
1699        raw_query: &str,
1700        query: &UpdateQuery,
1701    ) -> RedDBResult<RuntimeQueryResult> {
1702        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1703        // Issue #523 — blockchain collections are immutable. Reject before
1704        // RLS / RETURNING work so the operator sees a clean 409-mapped
1705        // error instead of a partially-applied mutation surface.
1706        if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
1707            return Err(RedDBError::InvalidOperation(format!(
1708                "BlockchainCollectionImmutable: UPDATE not allowed on '{}'",
1709                query.table
1710            )));
1711        }
1712        // Queue-shaped CLAIM (ADR 0020, #1609): a CLAIM on a queue collection
1713        // is a QueueLifecycle delivery acquisition, not a raw row UPDATE.
1714        // Route it through the lifecycle seam before the table-shaped
1715        // contract / RLS gates below (which would reject an UPDATE against a
1716        // queue's declared model) so QueueLifecycle stays the sole authority
1717        // for delivery state.
1718        if query.claim_limit.is_some() && self.is_queue_collection(&query.table) {
1719            return self.execute_queue_shaped_claim(raw_query, query);
1720        }
1721        // CollectionContract gate (#50): runs the APPEND ONLY guard
1722        // (and any future contract bits) before RLS / RETURNING work
1723        // so the operator's immutability declaration is honoured
1724        // uniformly and the error message points at the DDL rather
1725        // than at a downstream symptom.
1726        crate::runtime::collection_contract::CollectionContractGate::check(
1727            self,
1728            &query.table,
1729            crate::runtime::collection_contract::MutationKind::Update,
1730        )?;
1731        ensure_update_target_contract(self, &query.table, query.target)?;
1732        ensure_kv_key_update_target_allowed(query)?;
1733        ensure_graph_identity_update_target_allowed(query)?;
1734
1735        // Apply RLS augmentation first so every downstream path — plain
1736        // UPDATE, UPDATE...RETURNING, the inner scan — observes the
1737        // same policy-filtered target set. This prevents RETURNING
1738        // from ever exposing rows the UPDATE policy would have
1739        // denied.
1740        let rls_gated = crate::runtime::impl_core::rls_is_enabled(self, &query.table);
1741        let augmented_query: UpdateQuery;
1742        let effective_query: &UpdateQuery = if rls_gated {
1743            let update_filter = crate::runtime::impl_core::rls_policy_filter(
1744                self,
1745                &query.table,
1746                crate::storage::query::ast::PolicyAction::Update,
1747            );
1748            let Some(mut policy) = update_filter else {
1749                // No admitting policy: zero rows affected, empty
1750                // RETURNING (never leak rows the caller can't touch).
1751                let mut response = RuntimeQueryResult::dml_result(
1752                    raw_query.to_string(),
1753                    0,
1754                    "update",
1755                    "runtime-dml-rls",
1756                );
1757                if let Some(items) = query.returning.clone() {
1758                    response.result = build_returning_result(&items, &[], None);
1759                }
1760                return Ok(response);
1761            };
1762            if query.claim_limit.is_some() {
1763                let read_filter = crate::runtime::impl_core::rls_policy_filter(
1764                    self,
1765                    &query.table,
1766                    crate::storage::query::ast::PolicyAction::Select,
1767                );
1768                let Some(read_policy) = read_filter else {
1769                    let mut response = RuntimeQueryResult::dml_result(
1770                        raw_query.to_string(),
1771                        0,
1772                        "update",
1773                        "runtime-dml-rls",
1774                    );
1775                    if let Some(items) = query.returning.clone() {
1776                        response.result = build_returning_result(&items, &[], None);
1777                    }
1778                    return Ok(response);
1779                };
1780                policy = crate::storage::query::ast::Filter::And(
1781                    Box::new(read_policy),
1782                    Box::new(policy),
1783                );
1784            }
1785            let mut augmented = query.clone();
1786            augmented.filter = Some(match augmented.filter.take() {
1787                Some(existing) => {
1788                    crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
1789                }
1790                None => policy,
1791            });
1792            augmented_query = augmented;
1793            &augmented_query
1794        } else {
1795            query
1796        };
1797
1798        // RETURNING wraps the inner executor and uses the touched-id
1799        // list the inner reports so the post-image reflects exactly
1800        // the rows the UPDATE actually mutated (not whatever a
1801        // separate SELECT might have observed).
1802        if let Some(items) = effective_query.returning.clone() {
1803            let mut inner_query = effective_query.clone();
1804            inner_query.returning = None;
1805            let (mut response, touched_ids) =
1806                self.execute_update_inner_tracked(raw_query, &inner_query)?;
1807
1808            let mut snapshots = if matches!(
1809                effective_query.target,
1810                UpdateTarget::Nodes | UpdateTarget::Edges
1811            ) {
1812                graph_update_returning_snapshots(self, &effective_query.table, &touched_ids)
1813            } else {
1814                super::dml_target_scan::DmlTargetScan::new(self, &effective_query.table, None, None)
1815                    .row_snapshots(&touched_ids)
1816            };
1817            if matches!(effective_query.target, UpdateTarget::Kv) {
1818                restore_kv_returning_keys(
1819                    self,
1820                    &effective_query.table,
1821                    &touched_ids,
1822                    &mut snapshots,
1823                );
1824            }
1825
1826            response.result = build_returning_result(&items, &snapshots, None);
1827            response.engine = "runtime-dml-returning";
1828            return Ok(response);
1829        }
1830
1831        self.execute_update_inner(raw_query, effective_query)
1832    }
1833
1834    /// Back-compat shim: the older entry point ignored touched ids.
1835    fn execute_update_inner(
1836        &self,
1837        raw_query: &str,
1838        query: &UpdateQuery,
1839    ) -> RedDBResult<RuntimeQueryResult> {
1840        self.execute_update_inner_tracked(raw_query, query)
1841            .map(|(res, _)| res)
1842    }
1843
1844    /// Enforce the concurrent-claim ORDER BY index-gate (ADR 0063, #1607).
1845    ///
1846    /// A `CLAIM LIMIT n` / `CLAIM EXACT n` on a logical-identity model (tables
1847    /// and documents) must order its candidates through a compatible index; the
1848    /// planner rejects an ordering no index on the collection can serve rather
1849    /// than falling back to a broad write-path sort. KV (key identity) and graph
1850    /// nodes/edges are exempt — their claim identity is intrinsic, not a user
1851    /// ORDER BY column that needs a secondary index.
1852    fn enforce_claim_order_by_index_gate(&self, query: &UpdateQuery) -> RedDBResult<()> {
1853        if query.claim_limit.is_none()
1854            || !matches!(query.target, UpdateTarget::Rows | UpdateTarget::Documents)
1855        {
1856            return Ok(());
1857        }
1858        let available_indexes: Vec<Vec<String>> = self
1859            .index_store_ref()
1860            .list_indices(&query.table)
1861            .into_iter()
1862            .map(|index| index.columns)
1863            .collect();
1864        reddb_rql::planner::check_claim_order_by_index_gate(query, &available_indexes)
1865            .map_err(RedDBError::InvalidOperation)
1866    }
1867
1868    fn execute_update_inner_tracked(
1869        &self,
1870        raw_query: &str,
1871        query: &UpdateQuery,
1872    ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
1873        self.enforce_claim_order_by_index_gate(query)?;
1874        let store = self.inner.db.store();
1875        let effective_filter = effective_update_filter(query);
1876        let compiled_plan = self.compile_update_plan(query)?;
1877        let needs_rmw_lock = update_needs_rmw_lock(query);
1878        let claim_model = update_model_name(update_target_model(query.target));
1879        if query.claim_limit.is_some() {
1880            self.inner
1881                .claim_telemetry
1882                .record_attempt(&query.table, claim_model);
1883        }
1884        let claim_lock = query.claim_limit.map(|_| {
1885            self.inner
1886                .rmw_locks
1887                .lock_for(&query.table, "__table_claim_update__")
1888        });
1889        let _claim_guard = if let Some(lock) = claim_lock.as_ref() {
1890            let Some(guard) = lock.try_lock() else {
1891                let skipped_locked = query.claim_limit.unwrap_or(1);
1892                self.inner.claim_telemetry.record_skipped_locked(
1893                    &query.table,
1894                    claim_model,
1895                    skipped_locked,
1896                );
1897                tracing::debug!(
1898                    target: "reddb::claim",
1899                    collection = %query.table,
1900                    model = claim_model,
1901                    skipped_locked,
1902                    "concurrent claim skipped locked candidates"
1903                );
1904                return Ok((
1905                    RuntimeQueryResult::dml_result(
1906                        raw_query.to_string(),
1907                        0,
1908                        "update",
1909                        "runtime-dml",
1910                    ),
1911                    Vec::new(),
1912                ));
1913            };
1914            Some(guard)
1915        } else {
1916            None
1917        };
1918        let table_rmw_lock = if needs_rmw_lock {
1919            Some(
1920                self.inner
1921                    .rmw_locks
1922                    .lock_for(&query.table, "__table_rmw_update__"),
1923            )
1924        } else {
1925            None
1926        };
1927        let _table_rmw_guard = table_rmw_lock.as_ref().map(|lock| lock.lock());
1928        let mut touched_ids: Vec<EntityId> = Vec::new();
1929        let claim_cap = query.claim_limit.map(|limit| limit as usize);
1930        let limit_cap = claim_cap.or_else(|| query.limit.map(|limit| limit as usize));
1931        let manager = store
1932            .get_collection(&query.table)
1933            .ok_or_else(|| RedDBError::NotFound(query.table.clone()))?;
1934        let scan_limit = if query.order_by.is_empty() {
1935            limit_cap
1936        } else {
1937            None
1938        };
1939        let mut target_scan = super::dml_target_scan::DmlTargetScan::with_update_target(
1940            self,
1941            &query.table,
1942            effective_filter.as_ref(),
1943            scan_limit,
1944            query.target,
1945        );
1946        if needs_rmw_lock {
1947            target_scan = target_scan.with_live_table_rows();
1948        }
1949        let ids_to_update = target_scan.find_target_ids()?;
1950        let order_limit = if query.claim_limit.is_some() {
1951            None
1952        } else {
1953            limit_cap
1954        };
1955        let ids_to_update = if query.order_by.is_empty() {
1956            ids_to_update
1957        } else {
1958            ordered_update_target_ids(&manager, &ids_to_update, &query.order_by, order_limit)
1959        };
1960        let mut ids_to_update = if query.claim_limit.is_some() {
1961            self.filter_claim_locked_target_ids(&query.table, ids_to_update)
1962        } else {
1963            ids_to_update
1964        };
1965        if let Some(claim_cap) = claim_cap {
1966            ids_to_update.truncate(claim_cap);
1967        }
1968        if query.claim_exact
1969            && claim_cap.is_some_and(|claim_count| ids_to_update.len() < claim_count)
1970        {
1971            self.inner
1972                .claim_telemetry
1973                .record_miss(&query.table, claim_model);
1974            return Ok((
1975                RuntimeQueryResult::dml_result(raw_query.to_string(), 0, "update", "runtime-dml"),
1976                Vec::new(),
1977            ));
1978        }
1979
1980        if needs_rmw_lock {
1981            let result = self.execute_update_inner_tracked_locked(
1982                raw_query,
1983                query,
1984                &compiled_plan,
1985                &ids_to_update,
1986                effective_filter.as_ref(),
1987            )?;
1988            if query.claim_limit.is_some() {
1989                self.record_pending_claim_locks_for_touched_ids(&query.table, &result.1);
1990            }
1991            record_claim_outcome(
1992                &self.inner.claim_telemetry,
1993                query.claim_limit,
1994                &query.table,
1995                claim_model,
1996                result.0.affected_rows,
1997            );
1998            return Ok(result);
1999        }
2000
2001        let mut affected: u64 = 0;
2002        for chunk in ids_to_update.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2003            let mut applied_chunk = Vec::with_capacity(chunk.len());
2004            for entity in manager.get_many(chunk).into_iter().flatten() {
2005                let assignments =
2006                    self.materialize_update_assignments_for_entity(query, &entity, &compiled_plan)?;
2007                let applied = self.apply_materialized_update_for_entity(
2008                    query,
2009                    entity,
2010                    &compiled_plan,
2011                    assignments,
2012                )?;
2013                touched_ids.push(applied.id);
2014                applied_chunk.push(applied);
2015            }
2016            self.persist_update_chunk(&applied_chunk)?;
2017            affected += applied_chunk.len() as u64;
2018            let lsns = self.flush_update_chunk(&applied_chunk)?;
2019            if !query.suppress_events {
2020                self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
2021            }
2022        }
2023
2024        if affected > 0 {
2025            self.note_table_write(&query.table);
2026        }
2027        if query.claim_limit.is_some() {
2028            self.record_pending_claim_locks_for_touched_ids(&query.table, &touched_ids);
2029        }
2030        record_claim_outcome(
2031            &self.inner.claim_telemetry,
2032            query.claim_limit,
2033            &query.table,
2034            claim_model,
2035            affected,
2036        );
2037
2038        Ok((
2039            RuntimeQueryResult::dml_result(
2040                raw_query.to_string(),
2041                affected,
2042                "update",
2043                "runtime-dml",
2044            ),
2045            touched_ids,
2046        ))
2047    }
2048
2049    fn filter_claim_locked_target_ids(&self, table: &str, ids: Vec<EntityId>) -> Vec<EntityId> {
2050        let conn_id = current_connection_id();
2051        let locks = self.inner.pending_claim_locks.read();
2052        ids.into_iter()
2053            .filter(|id| {
2054                let Some(entity) = self.inner.db.store().get(table, *id) else {
2055                    return false;
2056                };
2057                let key = (table.to_string(), entity.logical_id());
2058                locks
2059                    .get(&key)
2060                    .is_none_or(|owner_conn_id| *owner_conn_id == conn_id)
2061            })
2062            .collect()
2063    }
2064
2065    fn record_pending_claim_locks_for_touched_ids(&self, table: &str, ids: &[EntityId]) {
2066        if self.current_xid().is_none() || ids.is_empty() {
2067            return;
2068        }
2069
2070        let conn_id = current_connection_id();
2071        let store = self.inner.db.store();
2072        let mut locks = self.inner.pending_claim_locks.write();
2073        for id in ids {
2074            let Some(entity) = store.get(table, *id) else {
2075                continue;
2076            };
2077            locks.insert((table.to_string(), entity.logical_id()), conn_id);
2078        }
2079    }
2080
2081    fn execute_update_inner_tracked_locked(
2082        &self,
2083        raw_query: &str,
2084        query: &UpdateQuery,
2085        compiled_plan: &CompiledUpdatePlan,
2086        ids_to_update: &[EntityId],
2087        effective_filter: Option<&Filter>,
2088    ) -> RedDBResult<(RuntimeQueryResult, Vec<EntityId>)> {
2089        let store = self.inner.db.store();
2090        let mut touched_ids = Vec::new();
2091        let mut lock_entries = Vec::new();
2092
2093        for id in ids_to_update {
2094            let Some(candidate) = store.get(&query.table, *id) else {
2095                continue;
2096            };
2097            let logical_id = candidate.logical_id();
2098            let lock_key = format!("row:{}", logical_id.raw());
2099            let rmw_lock = self.inner.rmw_locks.lock_for(&query.table, &lock_key);
2100            lock_entries.push((lock_key, logical_id, rmw_lock));
2101        }
2102
2103        lock_entries.sort_by(|left, right| left.0.cmp(&right.0));
2104        lock_entries.dedup_by(|left, right| left.0 == right.0);
2105        let _rmw_guards: Vec<_> = lock_entries.iter().map(|entry| entry.2.lock()).collect();
2106
2107        let mut applied_chunk = Vec::new();
2108        for (_, logical_id, _) in &lock_entries {
2109            let Some(entity) = resolve_update_entity_by_logical_id(self, &query.table, *logical_id)
2110            else {
2111                continue;
2112            };
2113            if let Some(filter) = effective_filter {
2114                if !crate::runtime::query_exec::evaluate_entity_filter_with_db(
2115                    Some(self.inner.db.as_ref()),
2116                    &entity,
2117                    filter,
2118                    &query.table,
2119                    &query.table,
2120                ) {
2121                    continue;
2122                }
2123            }
2124
2125            let assignments =
2126                self.materialize_update_assignments_for_entity(query, &entity, compiled_plan)?;
2127            let applied = self.apply_materialized_update_for_entity(
2128                query,
2129                entity,
2130                compiled_plan,
2131                assignments,
2132            )?;
2133            touched_ids.push(applied.id);
2134            applied_chunk.push(applied);
2135        }
2136
2137        let affected = applied_chunk.len() as u64;
2138        if !applied_chunk.is_empty() {
2139            self.persist_update_chunk(&applied_chunk)?;
2140            let lsns = self.flush_update_chunk(&applied_chunk)?;
2141            if !query.suppress_events {
2142                self.emit_update_events_for_collection(&query.table, &applied_chunk, &lsns)?;
2143            }
2144        }
2145
2146        if affected > 0 {
2147            self.note_table_write(&query.table);
2148        }
2149
2150        Ok((
2151            RuntimeQueryResult::dml_result(
2152                raw_query.to_string(),
2153                affected,
2154                "update",
2155                "runtime-dml",
2156            ),
2157            touched_ids,
2158        ))
2159    }
2160
2161    fn compile_update_plan(&self, query: &UpdateQuery) -> RedDBResult<CompiledUpdatePlan> {
2162        let mut static_field_assignments = Vec::new();
2163        let mut static_metadata_assignments = Vec::new();
2164        let mut dynamic_assignments = Vec::new();
2165        let row_contract_plan = build_row_update_contract_plan(&self.db(), &query.table)?;
2166        let mut row_modified_columns = Vec::new();
2167
2168        for (idx, (column, expr)) in query.assignment_exprs.iter().enumerate() {
2169            let compound_op = query.compound_assignment_ops.get(idx).copied().flatten();
2170            let metadata_key = resolve_sql_ttl_metadata_key(column);
2171            if compound_op.is_some() && metadata_key.is_some() {
2172                return Err(RedDBError::Query(format!(
2173                    "compound assignment is only supported for row fields: {column}"
2174                )));
2175            }
2176            if compound_op.is_none() {
2177                if let Ok(value) = fold_expr_to_value(expr.clone()) {
2178                    if let Some(metadata_key) = metadata_key {
2179                        let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2180                        let (canonical_key, canonical_value) =
2181                            canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2182                        static_metadata_assignments
2183                            .push((canonical_key.to_string(), canonical_value));
2184                    } else {
2185                        let value = self.resolve_crypto_sentinel(value)?;
2186                        static_field_assignments.push((
2187                            column.clone(),
2188                            normalize_row_update_assignment_with_plan(
2189                                &query.table,
2190                                column,
2191                                value,
2192                                row_contract_plan.as_ref(),
2193                            )?,
2194                        ));
2195                        row_modified_columns.push(column.clone());
2196                    }
2197                    continue;
2198                }
2199            }
2200
2201            dynamic_assignments.push(CompiledUpdateAssignment {
2202                column: column.clone(),
2203                expr: expr.clone(),
2204                compound_op,
2205                metadata_key,
2206                row_rule: if metadata_key.is_none() {
2207                    if let Some(plan) = row_contract_plan.as_ref() {
2208                        if plan.timestamps_enabled
2209                            && (column == "created_at" || column == "updated_at")
2210                        {
2211                            return Err(RedDBError::Query(format!(
2212                                "collection '{}' manages '{}' automatically — do not set it in UPDATE",
2213                                query.table, column
2214                            )));
2215                        }
2216                        if let Some(rule) = plan.declared_rules.get(column) {
2217                            Some(rule.clone())
2218                        } else if plan.strict_schema {
2219                            return Err(RedDBError::Query(format!(
2220                                "collection '{}' is strict and does not allow undeclared fields: {}",
2221                                query.table, column
2222                            )));
2223                        } else {
2224                            None
2225                        }
2226                    } else {
2227                        None
2228                    }
2229                } else {
2230                    None
2231                },
2232            });
2233            if metadata_key.is_none() {
2234                row_modified_columns.push(column.clone());
2235            }
2236        }
2237
2238        let row_modified_columns = dedupe_update_columns(row_modified_columns);
2239        let row_touches_unique_columns = row_contract_plan.as_ref().is_some_and(|plan| {
2240            row_modified_columns.iter().any(|column| {
2241                plan.unique_columns
2242                    .keys()
2243                    .any(|unique| unique.eq_ignore_ascii_case(column))
2244            })
2245        });
2246
2247        if let Some(ttl_ms) = query.ttl_ms {
2248            static_metadata_assignments
2249                .push(("_ttl_ms".to_string(), metadata_u64_to_value(ttl_ms)));
2250        }
2251        if let Some(expires_at_ms) = query.expires_at_ms {
2252            static_metadata_assignments.push((
2253                "_expires_at".to_string(),
2254                metadata_u64_to_value(expires_at_ms),
2255            ));
2256        }
2257        for (key, val) in &query.with_metadata {
2258            static_metadata_assignments.push((key.clone(), storage_value_to_metadata_value(val)));
2259        }
2260
2261        Ok(CompiledUpdatePlan {
2262            static_field_assignments,
2263            static_metadata_assignments,
2264            dynamic_assignments,
2265            row_contract_plan,
2266            row_modified_columns,
2267            row_touches_unique_columns,
2268        })
2269    }
2270
2271    fn materialize_update_assignments_for_entity(
2272        &self,
2273        query: &UpdateQuery,
2274        entity: &UnifiedEntity,
2275        compiled_plan: &CompiledUpdatePlan,
2276    ) -> RedDBResult<MaterializedUpdateAssignments> {
2277        let mut assignments = MaterializedUpdateAssignments::default();
2278        let mut record: Option<UnifiedRecord> = None;
2279
2280        for assignment in &compiled_plan.dynamic_assignments {
2281            if assignment.compound_op.is_some()
2282                && !matches!(
2283                    entity.data,
2284                    EntityData::Row(_) | EntityData::Node(_) | EntityData::Edge(_)
2285                )
2286            {
2287                return Err(RedDBError::Query(format!(
2288                    "compound assignment is only supported for row or graph UPDATE column '{}'",
2289                    assignment.column
2290                )));
2291            }
2292            if record.is_none() {
2293                record = runtime_any_record_from_entity_ref(entity);
2294            }
2295            let Some(record) = record.as_ref() else {
2296                return Err(RedDBError::Query(format!(
2297                    "UPDATE could not materialize runtime record for entity {} in '{}'",
2298                    entity.id.raw(),
2299                    query.table
2300                )));
2301            };
2302            let rhs = super::expr_eval::evaluate_runtime_expr_with_db(
2303                Some(self.inner.db.as_ref()),
2304                &assignment.expr,
2305                record,
2306                Some(query.table.as_str()),
2307                Some(query.table.as_str()),
2308            )
2309            .ok_or_else(|| {
2310                RedDBError::Query(format!(
2311                    "failed to evaluate UPDATE expression for column '{}'",
2312                    assignment.column
2313                ))
2314            })?;
2315            let value = if let Some(op) = assignment.compound_op {
2316                evaluate_compound_update_assignment(&assignment.column, record, op, rhs)?
2317            } else {
2318                rhs
2319            };
2320
2321            if let Some(metadata_key) = assignment.metadata_key {
2322                let raw_value = sql_literal_to_metadata_value(metadata_key, &value)?;
2323                let (canonical_key, canonical_value) =
2324                    canonicalize_sql_ttl_metadata(metadata_key, raw_value);
2325                assignments
2326                    .dynamic_metadata_assignments
2327                    .push((canonical_key.to_string(), canonical_value));
2328            } else {
2329                assignments.dynamic_field_assignments.push((
2330                    assignment.column.clone(),
2331                    normalize_row_update_value_for_rule(
2332                        &query.table,
2333                        self.resolve_crypto_sentinel(value)?,
2334                        assignment.row_rule.as_ref(),
2335                    )?,
2336                ));
2337            }
2338        }
2339
2340        Ok(assignments)
2341    }
2342
2343    fn apply_materialized_update_for_entity(
2344        &self,
2345        query: &UpdateQuery,
2346        entity: UnifiedEntity,
2347        compiled_plan: &CompiledUpdatePlan,
2348        mut assignments: MaterializedUpdateAssignments,
2349    ) -> RedDBResult<AppliedEntityMutation> {
2350        if matches!(query.target, UpdateTarget::Kv)
2351            && !compiled_plan
2352                .static_field_assignments
2353                .iter()
2354                .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2355            && !assignments
2356                .dynamic_field_assignments
2357                .iter()
2358                .any(|(column, _)| column.eq_ignore_ascii_case("key"))
2359        {
2360            if let Some(key) = runtime_any_record_from_entity_ref(&entity)
2361                .and_then(|record| record.get("key").cloned())
2362            {
2363                assignments
2364                    .dynamic_field_assignments
2365                    .push(("key".to_string(), key));
2366            }
2367        }
2368        if matches!(entity.data, EntityData::Row(_)) {
2369            return self.apply_loaded_sql_update_row_core(
2370                query.table.clone(),
2371                entity,
2372                &compiled_plan.static_field_assignments,
2373                assignments.dynamic_field_assignments,
2374                &compiled_plan.static_metadata_assignments,
2375                assignments.dynamic_metadata_assignments,
2376                compiled_plan.row_contract_plan.as_ref(),
2377                &compiled_plan.row_modified_columns,
2378                compiled_plan.row_touches_unique_columns,
2379            );
2380        }
2381
2382        ensure_graph_identity_update_allowed(&entity, compiled_plan, &assignments)?;
2383
2384        let operations = build_patch_operations_from_materialized_assignments(
2385            &entity,
2386            compiled_plan,
2387            assignments,
2388        );
2389        self.apply_loaded_patch_entity_core(
2390            query.table.clone(),
2391            entity,
2392            crate::json::Value::Null,
2393            operations,
2394        )
2395    }
2396
2397    /// Execute DELETE FROM table WHERE filter
2398    pub fn execute_delete(
2399        &self,
2400        raw_query: &str,
2401        query: &DeleteQuery,
2402    ) -> RedDBResult<RuntimeQueryResult> {
2403        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2404        // Issue #523 — blockchain collections are immutable; see
2405        // execute_update for the same gate.
2406        if crate::runtime::blockchain_kind::is_chain(self.inner.db.store().as_ref(), &query.table) {
2407            return Err(RedDBError::InvalidOperation(format!(
2408                "BlockchainCollectionImmutable: DELETE not allowed on '{}'",
2409                query.table
2410            )));
2411        }
2412        // CollectionContract gate (#50) — see execute_update for
2413        // rationale. The gate handles APPEND ONLY rejection and is
2414        // the single point where future contract bits land.
2415        crate::runtime::collection_contract::CollectionContractGate::check(
2416            self,
2417            &query.table,
2418            crate::runtime::collection_contract::MutationKind::Delete,
2419        )?;
2420
2421        // RETURNING on DELETE: capture the pre-image via an internal
2422        // SELECT that reuses the same WHERE, then run the delete with
2423        // the RETURNING clause stripped, then project the captured
2424        // rows through the requested items. The extra SELECT is a
2425        // pragmatic MVP — a future pass can fuse the scan with the
2426        // delete to avoid the second pass over the heap.
2427        if let Some(items) = query.returning.clone() {
2428            let select_sql = delete_to_select_sql(raw_query).ok_or_else(|| {
2429                RedDBError::Query(
2430                    "DELETE ... RETURNING: cannot rewrite query for pre-image scan".to_string(),
2431                )
2432            })?;
2433            let captured = self.execute_query(&select_sql)?;
2434
2435            let mut inner_query = query.clone();
2436            inner_query.returning = None;
2437            let _ = self.execute_delete(raw_query, &inner_query)?;
2438
2439            let snapshots: Vec<Vec<(String, Value)>> = captured
2440                .result
2441                .records
2442                .iter()
2443                .map(|rec| {
2444                    rec.iter_fields()
2445                        .map(|(k, v)| (k.as_ref().to_string(), v.clone()))
2446                        .collect()
2447                })
2448                .collect();
2449            let affected = snapshots.len() as u64;
2450            let result = build_returning_result(&items, &snapshots, None);
2451
2452            let mut response = RuntimeQueryResult::dml_result(
2453                raw_query.to_string(),
2454                affected,
2455                "delete",
2456                "runtime-dml-returning",
2457            );
2458            response.result = result;
2459            return Ok(response);
2460        }
2461        // Row-Level Security enforcement (Phase 2.5.2 PG parity).
2462        //
2463        // When the table has RLS enabled, gate the DELETE by the
2464        // per-role policy set: mutations only touch rows that *every*
2465        // matching `FOR DELETE` policy would accept. No policies =>
2466        // zero rows affected (PG restrictive-default).
2467        if crate::runtime::impl_core::rls_is_enabled(self, &query.table) {
2468            let rls_filter = crate::runtime::impl_core::rls_policy_filter(
2469                self,
2470                &query.table,
2471                crate::storage::query::ast::PolicyAction::Delete,
2472            );
2473            let Some(policy) = rls_filter else {
2474                return Ok(RuntimeQueryResult::dml_result(
2475                    raw_query.to_string(),
2476                    0,
2477                    "delete",
2478                    "runtime-dml-rls",
2479                ));
2480            };
2481            // Fold the policy predicate into the user's WHERE before
2482            // dispatching — the remainder of this function reads the
2483            // filter from `query` via `effective_delete_filter`, which
2484            // respects the updated value.
2485            let mut augmented = query.clone();
2486            augmented.filter = Some(match augmented.filter.take() {
2487                Some(existing) => {
2488                    crate::storage::query::ast::Filter::And(Box::new(existing), Box::new(policy))
2489                }
2490                None => policy,
2491            });
2492            return self.execute_delete_inner(raw_query, &augmented);
2493        }
2494        self.execute_delete_inner(raw_query, query)
2495    }
2496
2497    fn execute_delete_inner(
2498        &self,
2499        raw_query: &str,
2500        query: &DeleteQuery,
2501    ) -> RedDBResult<RuntimeQueryResult> {
2502        let effective_filter = effective_delete_filter(query);
2503
2504        // Find the rows that match the WHERE clause. The "find target
2505        // rows" loop lives in DmlTargetScan so UPDATE (#52) can reuse
2506        // the same scan strategy.
2507        let scan = super::dml_target_scan::DmlTargetScan::new(
2508            self,
2509            &query.table,
2510            effective_filter.as_ref(),
2511            None,
2512        );
2513        let ids_to_delete = scan.find_target_ids()?;
2514
2515        // For event-enabled collections, snapshot the pre-delete state
2516        // before rows are physically removed.
2517        let needs_delete_events =
2518            !query.suppress_events && self.collection_has_delete_subscriptions(&query.table);
2519        let mut pre_images: HashMap<u64, crate::json::Value> = if needs_delete_events {
2520            scan.row_json_pre_images(&ids_to_delete)
2521        } else {
2522            HashMap::new()
2523        };
2524
2525        let mut affected: u64 = 0;
2526        for chunk in ids_to_delete.chunks(UPDATE_APPLY_CHUNK_SIZE) {
2527            let (count, lsns) = self.delete_entities_batch(&query.table, chunk)?;
2528            affected += count;
2529            if needs_delete_events && !lsns.is_empty() {
2530                // lsns.len() == actually-deleted entities; align with chunk ids.
2531                // `delete_batch` may skip missing entities, so we correlate by
2532                // the number returned (they're emitted in chunk order).
2533                let deleted_chunk = &chunk[..lsns.len().min(chunk.len())];
2534                self.emit_delete_events_for_collection(
2535                    &query.table,
2536                    deleted_chunk,
2537                    &lsns,
2538                    &pre_images,
2539                )?;
2540            }
2541        }
2542        pre_images.clear();
2543
2544        if affected > 0 {
2545            self.note_table_write(&query.table);
2546        }
2547
2548        Ok(RuntimeQueryResult::dml_result(
2549            raw_query.to_string(),
2550            affected,
2551            "delete",
2552            "runtime-dml",
2553        ))
2554    }
2555}
2556
2557/// Reject UPDATE … NODES/EDGES that assign to graph identity/topology
2558/// columns regardless of whether any row matches the WHERE clause. The
2559/// per-entity guard below covers only the matched-rows case, but ADR 0019
2560/// declares these columns immutable on the surface itself, so a zero-row
2561/// UPDATE should still surface the same error to operators and SDKs.
2562fn ensure_graph_identity_update_target_allowed(query: &UpdateQuery) -> RedDBResult<()> {
2563    if !matches!(query.target, UpdateTarget::Nodes | UpdateTarget::Edges) {
2564        return Ok(());
2565    }
2566    for (column, _) in &query.assignment_exprs {
2567        if is_immutable_graph_identity_field(column) {
2568            return Err(RedDBError::Query(format!(
2569                "immutable graph field '{column}' cannot be updated"
2570            )));
2571        }
2572    }
2573    Ok(())
2574}
2575
2576fn ensure_kv_key_update_target_allowed(query: &UpdateQuery) -> RedDBResult<()> {
2577    if !matches!(query.target, UpdateTarget::Kv) {
2578        return Ok(());
2579    }
2580    for (column, _) in &query.assignment_exprs {
2581        if column.eq_ignore_ascii_case("key") {
2582            return Err(RedDBError::Query(
2583                "KV key cannot be updated; delete and insert a new key instead".to_string(),
2584            ));
2585        }
2586    }
2587    Ok(())
2588}
2589
2590fn ensure_graph_identity_update_allowed(
2591    entity: &UnifiedEntity,
2592    compiled_plan: &CompiledUpdatePlan,
2593    assignments: &MaterializedUpdateAssignments,
2594) -> RedDBResult<()> {
2595    if !matches!(entity.data, EntityData::Node(_) | EntityData::Edge(_)) {
2596        return Ok(());
2597    }
2598
2599    for (column, _) in compiled_plan
2600        .static_field_assignments
2601        .iter()
2602        .chain(assignments.dynamic_field_assignments.iter())
2603    {
2604        if is_immutable_graph_identity_field(column) {
2605            return Err(RedDBError::Query(format!(
2606                "immutable graph field '{column}' cannot be updated"
2607            )));
2608        }
2609    }
2610
2611    Ok(())
2612}
2613
2614fn is_immutable_graph_identity_field(column: &str) -> bool {
2615    ["rid", "label", "from_rid", "to_rid", "from", "to"]
2616        .iter()
2617        .any(|reserved| column.eq_ignore_ascii_case(reserved))
2618}
2619
2620fn build_patch_operations_from_materialized_assignments(
2621    entity: &UnifiedEntity,
2622    compiled_plan: &CompiledUpdatePlan,
2623    assignments: MaterializedUpdateAssignments,
2624) -> Vec<PatchEntityOperation> {
2625    let mut operations = Vec::with_capacity(
2626        compiled_plan.static_field_assignments.len()
2627            + compiled_plan.static_metadata_assignments.len()
2628            + assignments.dynamic_field_assignments.len()
2629            + assignments.dynamic_metadata_assignments.len(),
2630    );
2631
2632    for (column, value) in &compiled_plan.static_field_assignments {
2633        operations.push(PatchEntityOperation {
2634            op: PatchEntityOperationType::Set,
2635            path: update_patch_path_for_entity(entity, column),
2636            value: Some(storage_value_to_json(value)),
2637        });
2638    }
2639
2640    for (column, value) in assignments.dynamic_field_assignments {
2641        operations.push(PatchEntityOperation {
2642            op: PatchEntityOperationType::Set,
2643            path: update_patch_path_for_entity(entity, &column),
2644            value: Some(storage_value_to_json(&value)),
2645        });
2646    }
2647
2648    for (key, value) in &compiled_plan.static_metadata_assignments {
2649        operations.push(PatchEntityOperation {
2650            op: PatchEntityOperationType::Set,
2651            path: vec!["metadata".to_string(), key.clone()],
2652            value: Some(metadata_value_to_json(value)),
2653        });
2654    }
2655
2656    for (key, value) in assignments.dynamic_metadata_assignments {
2657        operations.push(PatchEntityOperation {
2658            op: PatchEntityOperationType::Set,
2659            path: vec!["metadata".to_string(), key],
2660            value: Some(metadata_value_to_json(&value)),
2661        });
2662    }
2663
2664    operations
2665}
2666
2667fn update_patch_path_for_entity(entity: &UnifiedEntity, column: &str) -> Vec<String> {
2668    if matches!(
2669        (&entity.kind, &entity.data),
2670        (
2671            crate::storage::EntityKind::GraphNode(_),
2672            EntityData::Node(_)
2673        )
2674    ) && column.eq_ignore_ascii_case("node_type")
2675    {
2676        return vec!["node_type".to_string()];
2677    }
2678    if matches!(
2679        (&entity.kind, &entity.data),
2680        (
2681            crate::storage::EntityKind::GraphEdge(_),
2682            EntityData::Edge(_)
2683        )
2684    ) && column.eq_ignore_ascii_case("weight")
2685    {
2686        return vec!["weight".to_string()];
2687    }
2688    vec!["fields".to_string(), column.to_string()]
2689}
2690
2691/// Rewrite `DELETE FROM <table> [WHERE …] [RETURNING …]` as
2692/// `SELECT * FROM <table> [WHERE …]` so the delete executor can
2693/// capture the pre-image before actually removing the rows. Returns
2694/// `None` when the input does not start with `DELETE`.
2695///
2696/// Case-insensitive on the keywords. Preserves everything between
2697/// the table name and the RETURNING clause, so WHERE / ORDER BY /
2698/// LIMIT survive untouched. The RETURNING tail — if present — is
2699/// truncated at the first top-level `RETURNING` token.
2700fn delete_to_select_sql(sql: &str) -> Option<String> {
2701    let trimmed = sql.trim_start();
2702    let lowered = trimmed.to_ascii_lowercase();
2703    if !lowered.starts_with("delete ") && !lowered.starts_with("delete\t") {
2704        return None;
2705    }
2706    // Find `FROM` after DELETE.
2707    let from_idx = lowered.find(" from ")?;
2708    let after_from = &trimmed[from_idx + " from ".len()..];
2709    let after_from_lc = &lowered[from_idx + " from ".len()..];
2710
2711    // Cut off the RETURNING tail (a naive search — the RETURNING
2712    // clause only appears once per statement at top level in our
2713    // grammar). Matches whitespace-bounded tokens to avoid clipping
2714    // `RETURNING` inside a string literal.
2715    let mut body = after_from.to_string();
2716    if let Some(pos) = find_top_level_keyword(after_from_lc, "returning") {
2717        body.truncate(pos);
2718    }
2719    Some(format!("SELECT * FROM {}", body.trim_end()))
2720}
2721
2722/// Find the byte offset of a whitespace-bounded keyword in a
2723/// lowercased haystack, skipping matches inside single-quoted
2724/// string literals. Naive — no escape handling — but enough for
2725/// the shapes the DML parser emits.
2726fn find_top_level_keyword(haystack: &str, needle: &str) -> Option<usize> {
2727    let bytes = haystack.as_bytes();
2728    let nlen = needle.len();
2729    let mut i = 0usize;
2730    let mut in_string = false;
2731    while i < bytes.len() {
2732        let c = bytes[i];
2733        if c == b'\'' {
2734            in_string = !in_string;
2735            i += 1;
2736            continue;
2737        }
2738        if !in_string
2739            && i + nlen <= bytes.len()
2740            && &bytes[i..i + nlen] == needle.as_bytes()
2741            && (i == 0 || bytes[i - 1].is_ascii_whitespace())
2742            && (i + nlen == bytes.len() || bytes[i + nlen].is_ascii_whitespace())
2743        {
2744            return Some(i);
2745        }
2746        i += 1;
2747    }
2748    None
2749}
2750
2751/// Build a `UnifiedResult` from the rows affected by a DML statement plus
2752/// its `RETURNING` clause. Each snapshot is a list of (column, value) pairs
2753/// for one affected row; `outputs`, when provided, supplies the engine-
2754/// assigned entity id for the same row (INSERT path). Projection honours
2755/// the RETURNING items: `*` expands to every snapshot column plus
2756/// the public row envelope when available.
2757fn build_returning_result(
2758    items: &[ReturningItem],
2759    snapshots: &[Vec<(String, Value)>],
2760    outputs: Option<&[CreateEntityOutput]>,
2761) -> UnifiedResult {
2762    let project_all = items.iter().any(|it| matches!(it, ReturningItem::All));
2763    let public_item_outputs = outputs.is_some_and(|outs| {
2764        outs.first()
2765            .and_then(|out| out.entity.as_ref())
2766            .is_some_and(|entity| public_returning_item_kind(entity).is_some())
2767    });
2768
2769    let mut columns: Vec<String> = if project_all {
2770        let mut cols: Vec<String> = Vec::new();
2771        if public_item_outputs {
2772            cols.extend(
2773                [
2774                    "rid",
2775                    "collection",
2776                    "kind",
2777                    "tenant",
2778                    "created_at",
2779                    "updated_at",
2780                ]
2781                .into_iter()
2782                .map(str::to_string),
2783            );
2784        } else if outputs.is_some() {
2785            cols.push("rid".to_string());
2786        }
2787        if let Some(first) = snapshots.first() {
2788            for (name, _) in first {
2789                cols.push(name.clone());
2790            }
2791        }
2792        cols
2793    } else {
2794        items
2795            .iter()
2796            .filter_map(|it| match it {
2797                ReturningItem::Column(c) => Some(c.clone()),
2798                ReturningItem::All => None,
2799            })
2800            .collect()
2801    };
2802    // Guarantee unique order-preserving column list.
2803    {
2804        let mut seen = std::collections::HashSet::new();
2805        columns.retain(|c| seen.insert(c.clone()));
2806    }
2807
2808    let mut records: Vec<UnifiedRecord> = Vec::with_capacity(snapshots.len());
2809    for (idx, snap) in snapshots.iter().enumerate() {
2810        let mut values: HashMap<Arc<str>, Value> = HashMap::with_capacity(columns.len());
2811        if let Some(outs) = outputs {
2812            if let Some(out) = outs.get(idx) {
2813                if let Some(entity) = out.entity.as_ref() {
2814                    if let Some(kind) = public_returning_item_kind(entity) {
2815                        values.insert(
2816                            Arc::clone(&sys_key_rid()),
2817                            Value::UnsignedInteger(out.id.raw()),
2818                        );
2819                        values.insert(
2820                            Arc::clone(&sys_key_collection()),
2821                            Value::text(entity.kind.collection().to_string()),
2822                        );
2823                        values.insert(Arc::clone(&sys_key_kind()), Value::text(kind.to_string()));
2824                        values.insert(
2825                            Arc::clone(&sys_key_created_at()),
2826                            Value::UnsignedInteger(entity.created_at),
2827                        );
2828                        values.insert(
2829                            Arc::clone(&sys_key_updated_at()),
2830                            Value::UnsignedInteger(entity.updated_at),
2831                        );
2832                    } else {
2833                        values.insert(
2834                            Arc::clone(&sys_key_rid()),
2835                            Value::Integer(out.id.raw() as i64),
2836                        );
2837                    }
2838                } else {
2839                    values.insert(
2840                        Arc::clone(&sys_key_rid()),
2841                        Value::Integer(out.id.raw() as i64),
2842                    );
2843                }
2844            }
2845        }
2846        for (name, val) in snap {
2847            values.insert(Arc::from(name.as_str()), val.clone());
2848        }
2849        if !values.contains_key("tenant") {
2850            let tenant = values.get("tenant_id").cloned().unwrap_or(Value::Null);
2851            values.insert(Arc::clone(&sys_key_tenant()), tenant);
2852        }
2853        let mut rec = UnifiedRecord::default();
2854        // Only keep projected columns on the record.
2855        for col in &columns {
2856            if let Some(v) = values.get(col.as_str()) {
2857                rec.set_arc(Arc::from(col.as_str()), v.clone());
2858            }
2859        }
2860        records.push(rec);
2861    }
2862
2863    UnifiedResult {
2864        columns,
2865        records,
2866        stats: Default::default(),
2867        pre_serialized_json: None,
2868    }
2869}
2870
2871fn public_returning_item_kind(entity: &crate::storage::UnifiedEntity) -> Option<&'static str> {
2872    match (&entity.kind, &entity.data) {
2873        (crate::storage::EntityKind::GraphNode(_), crate::storage::EntityData::Node(_)) => {
2874            Some("node")
2875        }
2876        (crate::storage::EntityKind::GraphEdge(_), crate::storage::EntityData::Edge(_)) => {
2877            Some("edge")
2878        }
2879        (_, crate::storage::EntityData::Row(_)) => Some(public_returning_row_kind(entity)),
2880        // #1369 — every entity model must expose its `rid` in RETURNING *.
2881        // Vectors carry their payload in `EntityData::Vector`, not `Row`, so
2882        // they were falling through to a no-rid envelope
2883        // and `RETURNING *` never surfaced the entity-id.
2884        (_, crate::storage::EntityData::Vector(_)) => Some("vector"),
2885        _ => None,
2886    }
2887}
2888
2889fn public_returning_row_kind(entity: &crate::storage::UnifiedEntity) -> &'static str {
2890    let Some(row) = entity.data.as_row() else {
2891        return "row";
2892    };
2893
2894    let is_kv = row.named.as_ref().is_some_and(|named| {
2895        (named.len() == 2 && named.contains_key("key") && named.contains_key("value"))
2896            || (named.len() == 1 && (named.contains_key("key") || named.contains_key("value")))
2897    });
2898    if is_kv {
2899        return "kv";
2900    }
2901
2902    let is_document = row
2903        .named
2904        .as_ref()
2905        .is_some_and(|named| named.values().any(runtime_returning_documentish_value))
2906        || row.columns.iter().any(runtime_returning_documentish_value);
2907    if is_document {
2908        "document"
2909    } else {
2910        "row"
2911    }
2912}
2913
2914fn runtime_returning_documentish_value(value: &Value) -> bool {
2915    matches!(value, Value::Json(_) | Value::Blob(_))
2916}
2917
2918fn row_insert_returning_snapshots(
2919    outputs: &[CreateEntityOutput],
2920    fallback: Vec<Vec<(String, Value)>>,
2921) -> Vec<Vec<(String, Value)>> {
2922    outputs
2923        .iter()
2924        .enumerate()
2925        .map(|(idx, out)| {
2926            out.entity
2927                .as_ref()
2928                .map(entity_row_fields_snapshot)
2929                .filter(|snap| !snap.is_empty())
2930                .unwrap_or_else(|| fallback.get(idx).cloned().unwrap_or_default())
2931        })
2932        .collect()
2933}
2934
2935fn graph_insert_returning_snapshots(
2936    store: &crate::storage::unified::UnifiedStore,
2937    collection: &str,
2938    ids: &[EntityId],
2939) -> Vec<Vec<(String, Value)>> {
2940    let Some(manager) = store.get_collection(collection) else {
2941        return Vec::new();
2942    };
2943
2944    ids.iter()
2945        .filter_map(|id| manager.get(*id))
2946        .filter_map(|entity| {
2947            let mut record = runtime_any_record_from_entity_ref(&entity)?;
2948            record.set_arc(sys_key_collection(), Value::text(collection.to_string()));
2949            Some(record)
2950        })
2951        .map(|record| {
2952            record
2953                .iter_fields()
2954                .map(|(key, value)| (key.as_ref().to_string(), value.clone()))
2955                .collect()
2956        })
2957        .collect()
2958}
2959
2960fn graph_update_returning_snapshots(
2961    runtime: &RedDBRuntime,
2962    collection: &str,
2963    ids: &[EntityId],
2964) -> Vec<Vec<(String, Value)>> {
2965    let store = runtime.db().store();
2966    let Some(manager) = store.get_collection(collection) else {
2967        return Vec::new();
2968    };
2969
2970    manager
2971        .get_many(ids)
2972        .into_iter()
2973        .flatten()
2974        .filter_map(|entity| runtime_any_record_from_entity_ref(&entity))
2975        .map(|record| {
2976            record
2977                .iter_fields()
2978                .map(|(key, value)| (key.as_ref().to_string(), value.clone()))
2979                .collect()
2980        })
2981        .collect()
2982}
2983
2984fn restore_kv_returning_keys(
2985    runtime: &RedDBRuntime,
2986    collection: &str,
2987    ids: &[EntityId],
2988    snapshots: &mut [Vec<(String, Value)>],
2989) {
2990    if ids.is_empty() || snapshots.is_empty() {
2991        return;
2992    }
2993
2994    let store = runtime.db().store();
2995    for (idx, id) in ids.iter().enumerate() {
2996        let Some(snapshot) = snapshots.get_mut(idx) else {
2997            continue;
2998        };
2999        if snapshot.iter().any(|(name, _)| name == "key") {
3000            continue;
3001        }
3002        let Some(entity) = store.get(collection, *id) else {
3003            continue;
3004        };
3005        let logical_id = entity.logical_id();
3006        let key = store
3007            .table_row_versions_by_logical_id(collection, logical_id)
3008            .into_iter()
3009            .find_map(kv_key_value_from_entity)
3010            .or_else(|| kv_key_value_from_entity(entity));
3011        if let Some(key) = key {
3012            snapshot.push(("key".to_string(), key));
3013        }
3014    }
3015}
3016
3017fn kv_key_value_from_entity(entity: UnifiedEntity) -> Option<Value> {
3018    let row = entity.data.as_row()?;
3019    row.get_field("key")
3020        .cloned()
3021        .or_else(|| row.columns.first().cloned())
3022}
3023
3024fn ensure_update_target_contract(
3025    runtime: &RedDBRuntime,
3026    collection: &str,
3027    target: UpdateTarget,
3028) -> RedDBResult<()> {
3029    let Some(contract) = runtime.db().collection_contract(collection) else {
3030        return Ok(());
3031    };
3032    if update_target_contract_is_advisory(&contract)
3033        || update_target_allows_model(contract.declared_model, update_target_model(target))
3034    {
3035        return Ok(());
3036    }
3037    Err(RedDBError::InvalidOperation(format!(
3038        "collection '{}' is declared as '{}' and does not allow '{}' updates",
3039        collection,
3040        update_model_name(contract.declared_model),
3041        update_model_name(update_target_model(target))
3042    )))
3043}
3044
3045fn update_target_contract_is_advisory(contract: &crate::physical::CollectionContract) -> bool {
3046    matches!(
3047        (&contract.origin, &contract.schema_mode),
3048        (
3049            crate::physical::ContractOrigin::Implicit,
3050            crate::catalog::SchemaMode::Dynamic,
3051        )
3052    )
3053}
3054
3055fn update_target_model(target: UpdateTarget) -> crate::catalog::CollectionModel {
3056    match target {
3057        UpdateTarget::Rows => crate::catalog::CollectionModel::Table,
3058        UpdateTarget::Documents => crate::catalog::CollectionModel::Document,
3059        UpdateTarget::Kv => crate::catalog::CollectionModel::Kv,
3060        UpdateTarget::Nodes | UpdateTarget::Edges => crate::catalog::CollectionModel::Graph,
3061    }
3062}
3063
3064fn update_target_allows_model(
3065    declared_model: crate::catalog::CollectionModel,
3066    requested_model: crate::catalog::CollectionModel,
3067) -> bool {
3068    declared_model == requested_model || declared_model == crate::catalog::CollectionModel::Mixed
3069}
3070
3071fn update_model_name(model: crate::catalog::CollectionModel) -> &'static str {
3072    match model {
3073        crate::catalog::CollectionModel::Table => "table",
3074        crate::catalog::CollectionModel::Document => "document",
3075        crate::catalog::CollectionModel::Graph => "graph",
3076        crate::catalog::CollectionModel::Vector => "vector",
3077        crate::catalog::CollectionModel::Hll => "hll",
3078        crate::catalog::CollectionModel::Sketch => "sketch",
3079        crate::catalog::CollectionModel::Filter => "filter",
3080        crate::catalog::CollectionModel::Kv => "kv",
3081        crate::catalog::CollectionModel::Config => "config",
3082        crate::catalog::CollectionModel::Vault => "vault",
3083        crate::catalog::CollectionModel::Mixed => "mixed",
3084        crate::catalog::CollectionModel::TimeSeries => "timeseries",
3085        crate::catalog::CollectionModel::Queue => "queue",
3086        crate::catalog::CollectionModel::Metrics => "metrics",
3087    }
3088}
3089
3090fn ensure_graph_insert_contract(runtime: &RedDBRuntime, collection: &str) -> RedDBResult<()> {
3091    let db = runtime.db();
3092    if let Some(contract) = db.collection_contract(collection) {
3093        let advisory_implicit_dynamic = matches!(
3094            (&contract.origin, &contract.schema_mode),
3095            (
3096                crate::physical::ContractOrigin::Implicit,
3097                crate::catalog::SchemaMode::Dynamic,
3098            )
3099        );
3100        if advisory_implicit_dynamic
3101            || matches!(
3102                contract.declared_model,
3103                crate::catalog::CollectionModel::Graph | crate::catalog::CollectionModel::Mixed
3104            )
3105        {
3106            return Ok(());
3107        }
3108        return Err(RedDBError::InvalidOperation(format!(
3109            "collection '{}' is declared as '{:?}' and does not allow 'Graph' writes",
3110            collection, contract.declared_model
3111        )));
3112    }
3113
3114    let now = std::time::SystemTime::now()
3115        .duration_since(std::time::UNIX_EPOCH)
3116        .unwrap_or_default()
3117        .as_millis();
3118    db.save_collection_contract(crate::physical::CollectionContract {
3119        name: collection.to_string(),
3120        declared_model: crate::catalog::CollectionModel::Graph,
3121        schema_mode: crate::catalog::SchemaMode::Dynamic,
3122        origin: crate::physical::ContractOrigin::Implicit,
3123        version: 1,
3124        created_at_unix_ms: now,
3125        updated_at_unix_ms: now,
3126        default_ttl_ms: db.collection_default_ttl_ms(collection),
3127        vector_dimension: None,
3128        vector_metric: None,
3129        context_index_fields: Vec::new(),
3130        declared_columns: Vec::new(),
3131        table_def: None,
3132        timestamps_enabled: false,
3133        context_index_enabled: false,
3134        metrics_raw_retention_ms: None,
3135        metrics_rollup_policies: Vec::new(),
3136        metrics_tenant_identity: None,
3137        metrics_namespace: None,
3138        append_only: false,
3139        subscriptions: Vec::new(),
3140        analytics_config: Vec::new(),
3141        session_key: None,
3142        session_gap_ms: None,
3143        retention_duration_ms: None,
3144        analytical_storage: None,
3145
3146        ai_policy: None,
3147    })
3148    .map(|_| ())
3149    .map_err(|err| RedDBError::Internal(err.to_string()))
3150}
3151
3152fn update_needs_rmw_lock(query: &UpdateQuery) -> bool {
3153    query
3154        .assignment_exprs
3155        .iter()
3156        .enumerate()
3157        .any(|(idx, (column, expr))| {
3158            query
3159                .compound_assignment_ops
3160                .get(idx)
3161                .is_some_and(|op| op.is_some())
3162                || expr_references_update_column(expr, &query.table, column)
3163        })
3164}
3165
3166fn record_claim_outcome(
3167    telemetry: &crate::runtime::claim_telemetry::ClaimTelemetryCounters,
3168    claim_limit: Option<u64>,
3169    table: &str,
3170    model: &str,
3171    affected: u64,
3172) {
3173    if claim_limit.is_none() {
3174        return;
3175    }
3176    if affected == 0 {
3177        telemetry.record_miss(table, model);
3178        tracing::debug!(
3179            target: "reddb::claim",
3180            collection = table,
3181            model,
3182            "concurrent claim missed"
3183        );
3184    } else {
3185        telemetry.record_successful(table, model, affected);
3186        tracing::debug!(
3187            target: "reddb::claim",
3188            collection = table,
3189            model,
3190            successful = affected,
3191            "concurrent claim succeeded"
3192        );
3193    }
3194}
3195
3196fn evaluate_compound_update_assignment(
3197    column: &str,
3198    record: &UnifiedRecord,
3199    op: BinOp,
3200    rhs: Value,
3201) -> RedDBResult<Value> {
3202    let lhs = record.get(column).ok_or_else(|| {
3203        RedDBError::Query(format!(
3204            "compound assignment requires existing numeric field '{column}'"
3205        ))
3206    })?;
3207    if matches!(lhs, Value::Null) {
3208        return Err(RedDBError::Query(format!(
3209            "compound assignment requires non-null numeric field '{column}'"
3210        )));
3211    }
3212    apply_compound_numeric_op(column, op, lhs, &rhs)
3213}
3214
3215fn apply_compound_numeric_op(
3216    column: &str,
3217    op: BinOp,
3218    lhs: &Value,
3219    rhs: &Value,
3220) -> RedDBResult<Value> {
3221    let Some(lhs_number) = CompoundNumber::from_value(lhs) else {
3222        return Err(RedDBError::Query(format!(
3223            "compound assignment requires numeric field '{column}'"
3224        )));
3225    };
3226    let Some(rhs_number) = CompoundNumber::from_value(rhs) else {
3227        return Err(RedDBError::Query(format!(
3228            "compound assignment requires numeric right-hand value for field '{column}'"
3229        )));
3230    };
3231
3232    if lhs_number.is_float() || rhs_number.is_float() || matches!(op, BinOp::Div) {
3233        let a = lhs_number.as_f64();
3234        let b = rhs_number.as_f64();
3235        let out = match op {
3236            BinOp::Add => a + b,
3237            BinOp::Sub => a - b,
3238            BinOp::Mul => a * b,
3239            BinOp::Div => {
3240                if b == 0.0 {
3241                    return Err(RedDBError::Query(format!(
3242                        "division by zero in compound assignment for field '{column}'"
3243                    )));
3244                }
3245                a / b
3246            }
3247            BinOp::Mod => {
3248                if b == 0.0 {
3249                    return Err(RedDBError::Query(format!(
3250                        "modulo by zero in compound assignment for field '{column}'"
3251                    )));
3252                }
3253                a % b
3254            }
3255            _ => {
3256                return Err(RedDBError::Query(format!(
3257                    "unsupported compound assignment operator for field '{column}'"
3258                )));
3259            }
3260        };
3261        if !out.is_finite() {
3262            return Err(RedDBError::Query(format!(
3263                "numeric overflow in compound assignment for field '{column}'"
3264            )));
3265        }
3266        return Ok(Value::Float(out));
3267    }
3268
3269    let a = lhs_number.as_i128();
3270    let b = rhs_number.as_i128();
3271    let out = match op {
3272        BinOp::Add => a.checked_add(b),
3273        BinOp::Sub => a.checked_sub(b),
3274        BinOp::Mul => a.checked_mul(b),
3275        BinOp::Mod => {
3276            if b == 0 {
3277                return Err(RedDBError::Query(format!(
3278                    "modulo by zero in compound assignment for field '{column}'"
3279                )));
3280            }
3281            a.checked_rem(b)
3282        }
3283        BinOp::Div => unreachable!("integer division is handled by the float branch"),
3284        _ => None,
3285    }
3286    .ok_or_else(|| {
3287        RedDBError::Query(format!(
3288            "numeric overflow in compound assignment for field '{column}'"
3289        ))
3290    })?;
3291
3292    if matches!(lhs, Value::UnsignedInteger(_)) {
3293        let value = u64::try_from(out).map_err(|_| {
3294            RedDBError::Query(format!(
3295                "numeric overflow in compound assignment for field '{column}'"
3296            ))
3297        })?;
3298        Ok(Value::UnsignedInteger(value))
3299    } else {
3300        let value = i64::try_from(out).map_err(|_| {
3301            RedDBError::Query(format!(
3302                "numeric overflow in compound assignment for field '{column}'"
3303            ))
3304        })?;
3305        Ok(Value::Integer(value))
3306    }
3307}
3308
3309#[derive(Clone, Copy)]
3310enum CompoundNumber {
3311    Integer(i128),
3312    Float(f64),
3313}
3314
3315impl CompoundNumber {
3316    fn from_value(value: &Value) -> Option<Self> {
3317        match value {
3318            Value::Integer(value) | Value::BigInt(value) => Some(Self::Integer(*value as i128)),
3319            Value::UnsignedInteger(value) => Some(Self::Integer(*value as i128)),
3320            Value::Float(value) => value.is_finite().then_some(Self::Float(*value)),
3321            Value::Decimal(value) => Some(Self::Float(*value as f64 / 10_000.0)),
3322            _ => None,
3323        }
3324    }
3325
3326    fn is_float(self) -> bool {
3327        matches!(self, Self::Float(_))
3328    }
3329
3330    fn as_f64(self) -> f64 {
3331        match self {
3332            Self::Integer(value) => value as f64,
3333            Self::Float(value) => value,
3334        }
3335    }
3336
3337    fn as_i128(self) -> i128 {
3338        match self {
3339            Self::Integer(value) => value,
3340            Self::Float(_) => unreachable!("float compound number used as integer"),
3341        }
3342    }
3343}
3344
3345fn expr_references_update_column(expr: &Expr, table_name: &str, target_column: &str) -> bool {
3346    match expr {
3347        Expr::Literal { .. } | Expr::Parameter { .. } | Expr::Subquery { .. } => false,
3348        Expr::Column { field, .. } => {
3349            field_ref_matches_update_column(field, table_name, target_column)
3350        }
3351        Expr::BinaryOp { lhs, rhs, .. } => {
3352            expr_references_update_column(lhs, table_name, target_column)
3353                || expr_references_update_column(rhs, table_name, target_column)
3354        }
3355        Expr::UnaryOp { operand, .. } | Expr::Cast { inner: operand, .. } => {
3356            expr_references_update_column(operand, table_name, target_column)
3357        }
3358        Expr::FunctionCall { args, .. } => args
3359            .iter()
3360            .any(|arg| expr_references_update_column(arg, table_name, target_column)),
3361        Expr::Case {
3362            branches, else_, ..
3363        } => {
3364            branches.iter().any(|(cond, value)| {
3365                expr_references_update_column(cond, table_name, target_column)
3366                    || expr_references_update_column(value, table_name, target_column)
3367            }) || else_
3368                .as_deref()
3369                .is_some_and(|expr| expr_references_update_column(expr, table_name, target_column))
3370        }
3371        Expr::IsNull { operand, .. } => {
3372            expr_references_update_column(operand, table_name, target_column)
3373        }
3374        Expr::InList { target, values, .. } => {
3375            expr_references_update_column(target, table_name, target_column)
3376                || values
3377                    .iter()
3378                    .any(|value| expr_references_update_column(value, table_name, target_column))
3379        }
3380        Expr::Between {
3381            target, low, high, ..
3382        } => {
3383            expr_references_update_column(target, table_name, target_column)
3384                || expr_references_update_column(low, table_name, target_column)
3385                || expr_references_update_column(high, table_name, target_column)
3386        }
3387        Expr::WindowFunctionCall { args, window, .. } => {
3388            args.iter()
3389                .any(|arg| expr_references_update_column(arg, table_name, target_column))
3390                || window
3391                    .partition_by
3392                    .iter()
3393                    .any(|e| expr_references_update_column(e, table_name, target_column))
3394                || window
3395                    .order_by
3396                    .iter()
3397                    .any(|o| expr_references_update_column(&o.expr, table_name, target_column))
3398        }
3399    }
3400}
3401
3402fn field_ref_matches_update_column(
3403    field: &FieldRef,
3404    table_name: &str,
3405    target_column: &str,
3406) -> bool {
3407    match field {
3408        FieldRef::TableColumn { table, column } => {
3409            column.eq_ignore_ascii_case(target_column)
3410                && (table.is_empty() || table.eq_ignore_ascii_case(table_name))
3411        }
3412        FieldRef::NodeProperty { .. } | FieldRef::EdgeProperty { .. } | FieldRef::NodeId { .. } => {
3413            false
3414        }
3415    }
3416}
3417
3418fn resolve_update_entity_by_logical_id(
3419    runtime: &RedDBRuntime,
3420    table: &str,
3421    logical_id: EntityId,
3422) -> Option<UnifiedEntity> {
3423    let store = runtime.inner.db.store();
3424    if let Some(entity) = store.get_table_row_by_logical_id(table, logical_id) {
3425        return Some(entity);
3426    }
3427    // Fallback for non-table-row entities (graph nodes/edges, etc.) where
3428    // entity_id == logical_id and the MVCC table-row resolver doesn't apply.
3429    store.get(table, logical_id)
3430}
3431
3432fn update_cdc_item_kind(
3433    runtime: &RedDBRuntime,
3434    collection: &str,
3435    entity: &UnifiedEntity,
3436) -> &'static str {
3437    match &entity.data {
3438        EntityData::Node(_) => return "node",
3439        EntityData::Edge(_) => return "edge",
3440        _ => {}
3441    }
3442
3443    match runtime
3444        .db()
3445        .collection_contract(collection)
3446        .map(|contract| contract.declared_model)
3447    {
3448        Some(crate::catalog::CollectionModel::Document) => "document",
3449        Some(crate::catalog::CollectionModel::Kv)
3450        | Some(crate::catalog::CollectionModel::Vault) => "kv",
3451        _ => "row",
3452    }
3453}
3454
3455fn ordered_update_target_ids(
3456    manager: &Arc<crate::storage::SegmentManager>,
3457    entity_ids: &[EntityId],
3458    order_by: &[OrderByClause],
3459    limit: Option<usize>,
3460) -> Vec<EntityId> {
3461    let mut entities: Vec<UnifiedEntity> =
3462        manager.get_many(entity_ids).into_iter().flatten().collect();
3463    entities.sort_by(|left, right| compare_update_order(left, right, order_by));
3464    if let Some(limit) = limit {
3465        entities.truncate(limit);
3466    }
3467    entities.into_iter().map(|entity| entity.id).collect()
3468}
3469
3470fn compare_update_order(
3471    left: &UnifiedEntity,
3472    right: &UnifiedEntity,
3473    order_by: &[OrderByClause],
3474) -> Ordering {
3475    for clause in order_by {
3476        let left_value = update_order_value(left, &clause.field);
3477        let right_value = update_order_value(right, &clause.field);
3478        let ordering = compare_update_order_values(
3479            left_value.as_ref(),
3480            right_value.as_ref(),
3481            clause.nulls_first,
3482        );
3483        if ordering != Ordering::Equal {
3484            return if clause.ascending {
3485                ordering
3486            } else {
3487                ordering.reverse()
3488            };
3489        }
3490    }
3491    left.logical_id().raw().cmp(&right.logical_id().raw())
3492}
3493
3494fn compare_update_order_values(
3495    left: Option<&Value>,
3496    right: Option<&Value>,
3497    nulls_first: bool,
3498) -> Ordering {
3499    match (left, right) {
3500        (None, None) => Ordering::Equal,
3501        (None, Some(_)) => {
3502            if nulls_first {
3503                Ordering::Less
3504            } else {
3505                Ordering::Greater
3506            }
3507        }
3508        (Some(_), None) => {
3509            if nulls_first {
3510                Ordering::Greater
3511            } else {
3512                Ordering::Less
3513            }
3514        }
3515        (Some(left), Some(right)) => {
3516            crate::storage::query::value_compare::total_compare_values(left, right)
3517        }
3518    }
3519}
3520
3521fn update_order_value(entity: &UnifiedEntity, field: &FieldRef) -> Option<Value> {
3522    let FieldRef::TableColumn { table, column } = field else {
3523        return None;
3524    };
3525    if !table.is_empty() {
3526        return None;
3527    }
3528    if column.eq_ignore_ascii_case("rid") {
3529        return Some(Value::UnsignedInteger(entity.logical_id().raw()));
3530    }
3531    match &entity.data {
3532        // After the single-source binary-body cutover (ADR 0063) a DOCUMENT's
3533        // top-level fields live only inside the binary `body` container, not as
3534        // promoted row fields, so a direct `get_field` misses them and the
3535        // claim/UPDATE `ORDER BY <body-field>` would silently fall back to
3536        // insertion order. Mirror the filter read-seam: when the field isn't a
3537        // direct row field, offset-read it from the binary body.
3538        EntityData::Row(row) => {
3539            row.get_field(column)
3540                .cloned()
3541                .or_else(|| match row.get_field("body") {
3542                    Some(Value::Json(bytes)) => {
3543                        crate::document_body::read_body_field(bytes, column)
3544                    }
3545                    _ => None,
3546                })
3547        }
3548        EntityData::Node(_) | EntityData::Edge(_) => runtime_any_record_from_entity_ref(entity)
3549            .and_then(|record| record.get(column).cloned()),
3550        _ => None,
3551    }
3552}
3553
3554fn dedupe_update_columns(mut columns: Vec<String>) -> Vec<String> {
3555    if columns.is_empty() {
3556        return columns;
3557    }
3558
3559    let mut unique = Vec::with_capacity(columns.len());
3560    for column in columns.drain(..) {
3561        if !unique
3562            .iter()
3563            .any(|existing: &String| existing.eq_ignore_ascii_case(&column))
3564        {
3565            unique.push(column);
3566        }
3567    }
3568    unique
3569}
3570
3571// =============================================================================
3572// Helper functions for extracting typed values from column/value pairs
3573// =============================================================================
3574
3575const SQL_TTL_METADATA_COLUMNS: [&str; 3] = ["_ttl", "_ttl_ms", "_expires_at"];
3576
3577fn resolve_sql_ttl_metadata_key(column: &str) -> Option<&'static str> {
3578    if column.eq_ignore_ascii_case("_ttl") {
3579        Some(SQL_TTL_METADATA_COLUMNS[0])
3580    } else if column.eq_ignore_ascii_case("_ttl_ms") {
3581        Some(SQL_TTL_METADATA_COLUMNS[1])
3582    } else if column.eq_ignore_ascii_case("_expires_at") {
3583        Some(SQL_TTL_METADATA_COLUMNS[2])
3584    } else {
3585        None
3586    }
3587}
3588
3589/// Canonicalize a SQL TTL metadata `(key, value)` pair so the retention
3590/// sweeper sees a single key (`_ttl_ms`) regardless of which legacy form
3591/// the operator wrote. `_ttl` is scaled from seconds to milliseconds;
3592/// `_ttl_ms` and `_expires_at` are passed through.
3593fn canonicalize_sql_ttl_metadata(
3594    key: &'static str,
3595    value: MetadataValue,
3596) -> (&'static str, MetadataValue) {
3597    if key != "_ttl" {
3598        return (key, value);
3599    }
3600    let scaled = match value {
3601        MetadataValue::Int(s) => MetadataValue::Int(s.saturating_mul(1_000)),
3602        MetadataValue::Timestamp(ms_or_s) => {
3603            // Timestamp is already chosen for very large values; treat as
3604            // already-ms to avoid silent overflow.
3605            MetadataValue::Timestamp(ms_or_s)
3606        }
3607        MetadataValue::Float(f) => MetadataValue::Float(f * 1_000.0),
3608        other => other,
3609    };
3610    ("_ttl_ms", scaled)
3611}
3612
3613/// Sentinel prefix produced by the parser for `PASSWORD('...')` and
3614/// `SECRET('...')` literals. The runtime strips this marker and
3615/// applies the actual crypto transform during INSERT execution.
3616pub(crate) const PLAINTEXT_SENTINEL: &str = "@@plain@@";
3617
3618impl RedDBRuntime {
3619    /// Strip the plaintext sentinel from a `Value::Password` or
3620    /// `Value::Secret` produced by the parser and apply the real
3621    /// crypto transform. `Password` is always hashed with argon2id.
3622    /// `Secret` is encrypted with AES-256-GCM keyed by the vault
3623    /// when `red.config.secret.auto_encrypt = true` (default).
3624    pub(crate) fn resolve_crypto_sentinel(&self, value: Value) -> RedDBResult<Value> {
3625        match value {
3626            Value::Password(marked) => {
3627                if let Some(plain) = marked.strip_prefix(PLAINTEXT_SENTINEL) {
3628                    Ok(Value::Password(crate::auth::store::hash_password(plain)))
3629                } else {
3630                    Ok(Value::Password(marked))
3631                }
3632            }
3633            Value::Secret(bytes) => {
3634                if bytes.starts_with(PLAINTEXT_SENTINEL.as_bytes()) {
3635                    if !self.secret_auto_encrypt() {
3636                        return Err(RedDBError::Query(
3637                            "SECRET() literal rejected: red.config.secret.auto_encrypt \
3638                             is false. Insert pre-encrypted bytes directly instead."
3639                                .to_string(),
3640                        ));
3641                    }
3642                    let key = self.secret_aes_key().ok_or_else(|| {
3643                        RedDBError::Query(
3644                            "SECRET() column encryption requires a bootstrapped \
3645                             vault (red.secret.aes_key is missing). Start the server \
3646                             with --vault to enable."
3647                                .to_string(),
3648                        )
3649                    })?;
3650                    let plain = &bytes[PLAINTEXT_SENTINEL.len()..];
3651                    Ok(Value::Secret(encrypt_secret_payload(&key, plain)))
3652                } else {
3653                    Ok(Value::Secret(bytes))
3654                }
3655            }
3656            other => Ok(other),
3657        }
3658    }
3659}
3660
3661/// Encode an AES-256-GCM ciphertext as `[12-byte nonce][ciphertext||tag]`.
3662/// This is the on-disk representation of `Value::Secret`.
3663fn encrypt_secret_payload(key: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
3664    let nonce_bytes = crate::auth::store::random_bytes(12);
3665    let mut nonce = [0u8; 12];
3666    nonce.copy_from_slice(&nonce_bytes[..12]);
3667    let ct = crate::crypto::aes_gcm::aes256_gcm_encrypt(key, &nonce, b"reddb.secret", plaintext);
3668    let mut out = Vec::with_capacity(12 + ct.len());
3669    out.extend_from_slice(&nonce);
3670    out.extend_from_slice(&ct);
3671    out
3672}
3673
3674/// Decode a `Value::Secret` payload back to plaintext. Returns
3675/// `None` when the payload is too short or AES-GCM authentication
3676/// fails (tampered or wrong key).
3677pub(crate) fn decrypt_secret_payload(key: &[u8; 32], payload: &[u8]) -> Option<Vec<u8>> {
3678    if payload.len() < 12 {
3679        return None;
3680    }
3681    let mut nonce = [0u8; 12];
3682    nonce.copy_from_slice(&payload[..12]);
3683    crate::crypto::aes_gcm::aes256_gcm_decrypt(key, &nonce, b"reddb.secret", &payload[12..]).ok()
3684}
3685
3686fn split_insert_metadata(
3687    runtime: &RedDBRuntime,
3688    columns: &[String],
3689    values: &[Value],
3690) -> RedDBResult<(Vec<(String, Value)>, Vec<(String, MetadataValue)>)> {
3691    let mut fields = Vec::new();
3692    let mut metadata = Vec::new();
3693
3694    for (column, value) in columns.iter().zip(values.iter()) {
3695        // Still support legacy _ttl columns for backward compat
3696        if let Some(metadata_key) = resolve_sql_ttl_metadata_key(column) {
3697            let raw_value = sql_literal_to_metadata_value(metadata_key, value)?;
3698            let (canonical_key, canonical_value) =
3699                canonicalize_sql_ttl_metadata(metadata_key, raw_value);
3700            metadata.push((canonical_key.to_string(), canonical_value));
3701            continue;
3702        }
3703        fields.push((
3704            column.clone(),
3705            runtime.resolve_crypto_sentinel(value.clone())?,
3706        ));
3707    }
3708
3709    Ok((fields, metadata))
3710}
3711
3712/// Merge structured WITH TTL, WITH EXPIRES AT, and WITH METADATA clauses into metadata entries.
3713fn merge_with_clauses(
3714    metadata: &mut Vec<(String, MetadataValue)>,
3715    ttl_ms: Option<u64>,
3716    expires_at_ms: Option<u64>,
3717    with_metadata: &[(String, Value)],
3718) {
3719    if let Some(ms) = ttl_ms {
3720        metadata.push((
3721            "_ttl_ms".to_string(),
3722            if ms <= i64::MAX as u64 {
3723                MetadataValue::Int(ms as i64)
3724            } else {
3725                MetadataValue::Timestamp(ms)
3726            },
3727        ));
3728    }
3729    if let Some(ms) = expires_at_ms {
3730        metadata.push(("_expires_at".to_string(), MetadataValue::Timestamp(ms)));
3731    }
3732    for (key, value) in with_metadata {
3733        let meta_value = match value {
3734            Value::Text(s) => MetadataValue::String(s.to_string()),
3735            Value::Integer(n) => MetadataValue::Int(*n),
3736            Value::Float(n) => MetadataValue::Float(*n),
3737            Value::Boolean(b) => MetadataValue::Bool(*b),
3738            _ => MetadataValue::String(value.to_string()),
3739        };
3740        metadata.push((key.clone(), meta_value));
3741    }
3742}
3743
3744fn merge_vector_metadata_column(
3745    metadata: &mut Vec<(String, MetadataValue)>,
3746    columns: &[String],
3747    values: &[Value],
3748) -> RedDBResult<()> {
3749    let Some(value) = columns
3750        .iter()
3751        .position(|column| column.eq_ignore_ascii_case("metadata"))
3752        .map(|index| &values[index])
3753    else {
3754        return Ok(());
3755    };
3756    let json = match value {
3757        Value::Null => return Ok(()),
3758        Value::Json(bytes) => crate::json::from_slice(bytes).map_err(|err| {
3759            RedDBError::Query(format!("column 'metadata' invalid JSON object: {err}"))
3760        })?,
3761        Value::Text(text) => crate::json::from_str(text).map_err(|err| {
3762            RedDBError::Query(format!("column 'metadata' invalid JSON object: {err}"))
3763        })?,
3764        other => {
3765            return Err(RedDBError::Query(format!(
3766                "column 'metadata' expected JSON object, got {other:?}"
3767            )))
3768        }
3769    };
3770    let parsed = metadata_from_json(&json)?;
3771    for (key, value) in parsed.iter() {
3772        metadata.push((key.clone(), value.clone()));
3773    }
3774    Ok(())
3775}
3776
3777fn apply_collection_default_ttl_metadata(
3778    runtime: &RedDBRuntime,
3779    collection: &str,
3780    metadata: &mut Vec<(String, MetadataValue)>,
3781) {
3782    if has_internal_ttl_metadata(metadata) {
3783        return;
3784    }
3785
3786    let Some(default_ttl_ms) = runtime.db().collection_default_ttl_ms(collection) else {
3787        return;
3788    };
3789
3790    metadata.push((
3791        "_ttl_ms".to_string(),
3792        if default_ttl_ms <= i64::MAX as u64 {
3793            MetadataValue::Int(default_ttl_ms as i64)
3794        } else {
3795            MetadataValue::Timestamp(default_ttl_ms)
3796        },
3797    ));
3798}
3799
3800fn ensure_non_tree_reserved_metadata_entries(
3801    metadata: &[(String, MetadataValue)],
3802) -> RedDBResult<()> {
3803    for (key, _) in metadata {
3804        ensure_non_tree_reserved_metadata_key(key)?;
3805    }
3806    Ok(())
3807}
3808
3809fn ensure_non_tree_reserved_metadata_key(key: &str) -> RedDBResult<()> {
3810    if key.starts_with(TREE_METADATA_PREFIX) {
3811        return Err(RedDBError::Query(format!(
3812            "metadata key '{}' is reserved for managed trees",
3813            key
3814        )));
3815    }
3816    Ok(())
3817}
3818
3819fn ensure_non_tree_structural_edge_label(label: &str) -> RedDBResult<()> {
3820    if label.eq_ignore_ascii_case(TREE_CHILD_EDGE_LABEL) {
3821        return Err(RedDBError::Query(format!(
3822            "edge label '{}' is reserved for managed trees",
3823            TREE_CHILD_EDGE_LABEL
3824        )));
3825    }
3826    Ok(())
3827}
3828
3829fn pairwise_columns_values(pairs: &[(String, Value)]) -> (Vec<String>, Vec<Value>) {
3830    let mut columns = Vec::with_capacity(pairs.len());
3831    let mut values = Vec::with_capacity(pairs.len());
3832
3833    for (column, value) in pairs {
3834        columns.push(column.clone());
3835        values.push(value.clone());
3836    }
3837
3838    (columns, values)
3839}
3840
3841/// Find a required column value and return it as-is.
3842fn find_column_value(columns: &[String], values: &[Value], name: &str) -> RedDBResult<Value> {
3843    for (i, col) in columns.iter().enumerate() {
3844        if col.eq_ignore_ascii_case(name) {
3845            return Ok(values[i].clone());
3846        }
3847    }
3848    Err(RedDBError::Query(format!(
3849        "required column '{name}' not found in INSERT"
3850    )))
3851}
3852
3853/// Find a required column value and coerce to String.
3854fn find_column_value_string(
3855    columns: &[String],
3856    values: &[Value],
3857    name: &str,
3858) -> RedDBResult<String> {
3859    let val = find_column_value(columns, values, name)?;
3860    match val {
3861        Value::Text(s) => Ok(s.to_string()),
3862        Value::Integer(n) => Ok(n.to_string()),
3863        Value::Float(n) => Ok(n.to_string()),
3864        other => Err(RedDBError::Query(format!(
3865            "column '{name}' expected text, got {other:?}"
3866        ))),
3867    }
3868}
3869
3870fn find_document_body_json(
3871    columns: &[String],
3872    values: &[Value],
3873) -> RedDBResult<crate::json::Value> {
3874    let val = find_column_value(columns, values, "body")?;
3875    match val {
3876        Value::Json(bytes) | Value::Blob(bytes) => {
3877            if let Some(body) = crate::document_body::decode_container_to_json(&bytes) {
3878                Ok(body)
3879            } else {
3880                crate::json::from_slice(&bytes)
3881                    .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}")))
3882            }
3883        }
3884        Value::Text(text) => crate::json::from_str(text.as_ref())
3885            .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3886        Value::Integer(value) => crate::json::from_str(&value.to_string())
3887            .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3888        Value::UnsignedInteger(value) => crate::json::from_str(&value.to_string())
3889            .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3890        Value::Float(value) => crate::json::from_str(&value.to_string())
3891            .map_err(|err| RedDBError::Query(format!("invalid JSON body: {err}"))),
3892        other => Err(RedDBError::Query(format!(
3893            "column 'body' expected JSON body, got {other:?}"
3894        ))),
3895    }
3896}
3897
3898fn find_column_value_f64(columns: &[String], values: &[Value], name: &str) -> RedDBResult<f64> {
3899    let val = find_column_value(columns, values, name)?;
3900    match val {
3901        Value::Float(n) => Ok(n),
3902        Value::Integer(n) => Ok(n as f64),
3903        Value::UnsignedInteger(n) => Ok(n as f64),
3904        Value::Text(s) => s
3905            .parse::<f64>()
3906            .map_err(|_| RedDBError::Query(format!("column '{name}' expected number, got '{s}'"))),
3907        other => Err(RedDBError::Query(format!(
3908            "column '{name}' expected number, got {other:?}"
3909        ))),
3910    }
3911}
3912
3913/// Find an optional column value as String.
3914fn find_column_value_opt_string(
3915    columns: &[String],
3916    values: &[Value],
3917    name: &str,
3918) -> Option<String> {
3919    for (i, col) in columns.iter().enumerate() {
3920        if col.eq_ignore_ascii_case(name) {
3921            return match &values[i] {
3922                Value::Null => None,
3923                Value::Text(s) => Some(s.to_string()),
3924                Value::Integer(n) => Some(n.to_string()),
3925                Value::Float(n) => Some(n.to_string()),
3926                _ => None,
3927            };
3928        }
3929    }
3930    None
3931}
3932
3933/// Resolve an EDGE endpoint (`from`/`to`) to a numeric entity id.
3934///
3935/// Accepts integer literals, decimal strings, and node labels resolved via
3936/// the per-collection graph label index (same source of truth that
3937/// `GRAPH NEIGHBORHOOD` / `GRAPH TRAVERSE` use at query time). Ambiguous
3938/// labels error so callers can fall back to the numeric id form.
3939fn resolve_edge_endpoint(
3940    store: &crate::storage::unified::UnifiedStore,
3941    collection: &str,
3942    columns: &[String],
3943    values: &[Value],
3944    name: &str,
3945) -> RedDBResult<u64> {
3946    let val = find_column_value(columns, values, name)?;
3947    match val {
3948        Value::Integer(n) => Ok(n as u64),
3949        Value::UnsignedInteger(n) => Ok(n),
3950        Value::Text(s) => {
3951            if let Ok(n) = s.parse::<u64>() {
3952                return Ok(n);
3953            }
3954            let matches = store.lookup_graph_nodes_by_label_in(collection, &s);
3955            match matches.len() {
3956                0 => Err(RedDBError::Query(format!(
3957                    "column '{name}': no graph node with label '{s}' in collection '{collection}'"
3958                ))),
3959                1 => Ok(matches[0].raw()),
3960                n => Err(RedDBError::Query(format!(
3961                    "column '{name}': ambiguous label '{s}' matches {n} nodes in collection '{collection}'; use the numeric id"
3962                ))),
3963            }
3964        }
3965        other => Err(RedDBError::Query(format!(
3966            "column '{name}' expected integer or node label, got {other:?}"
3967        ))),
3968    }
3969}
3970
3971fn resolve_edge_endpoint_any(
3972    store: &crate::storage::unified::UnifiedStore,
3973    collection: &str,
3974    columns: &[String],
3975    values: &[Value],
3976    names: &[&str],
3977) -> RedDBResult<u64> {
3978    for name in names {
3979        if columns
3980            .iter()
3981            .any(|column| column.eq_ignore_ascii_case(name))
3982        {
3983            return resolve_edge_endpoint(store, collection, columns, values, name);
3984        }
3985    }
3986
3987    Err(RedDBError::Query(format!(
3988        "required column '{}' not found in INSERT",
3989        names.first().copied().unwrap_or("from_rid")
3990    )))
3991}
3992
3993/// Find a required column value and coerce to u64.
3994fn find_column_value_u64(columns: &[String], values: &[Value], name: &str) -> RedDBResult<u64> {
3995    let val = find_column_value(columns, values, name)?;
3996    match val {
3997        Value::Integer(n) => Ok(n as u64),
3998        Value::UnsignedInteger(n) => Ok(n),
3999        Value::Text(s) => s
4000            .parse::<u64>()
4001            .map_err(|_| RedDBError::Query(format!("column '{name}' expected integer, got '{s}'"))),
4002        other => Err(RedDBError::Query(format!(
4003            "column '{name}' expected integer, got {other:?}"
4004        ))),
4005    }
4006}
4007
4008/// Find an optional column value as f32.
4009fn find_column_value_f32_opt(columns: &[String], values: &[Value], name: &str) -> Option<f32> {
4010    for (i, col) in columns.iter().enumerate() {
4011        if col.eq_ignore_ascii_case(name) {
4012            return match &values[i] {
4013                Value::Float(n) => Some(*n as f32),
4014                Value::Integer(n) => Some(*n as f32),
4015                Value::Null => None,
4016                _ => None,
4017            };
4018        }
4019    }
4020    None
4021}
4022
4023/// Find a required column value and coerce to Vec<f32> (from Value::Vector).
4024fn find_column_value_vec_f32(
4025    columns: &[String],
4026    values: &[Value],
4027    name: &str,
4028) -> RedDBResult<Vec<f32>> {
4029    let val = find_column_value(columns, values, name)?;
4030    match val {
4031        Value::Vector(v) => Ok(v),
4032        Value::Json(bytes) => {
4033            // Try to parse as JSON array of numbers
4034            let s = std::str::from_utf8(&bytes).map_err(|_| {
4035                RedDBError::Query(format!("column '{name}' contains invalid UTF-8"))
4036            })?;
4037            let arr: Vec<f32> = crate::json::from_str(s).map_err(|e| {
4038                RedDBError::Query(format!("column '{name}' invalid vector JSON: {e}"))
4039            })?;
4040            Ok(arr)
4041        }
4042        other => Err(RedDBError::Query(format!(
4043            "column '{name}' expected vector, got {other:?}"
4044        ))),
4045    }
4046}
4047
4048fn find_column_value_vec_f32_any(
4049    columns: &[String],
4050    values: &[Value],
4051    names: &[&str],
4052) -> RedDBResult<Vec<f32>> {
4053    for name in names {
4054        if columns
4055            .iter()
4056            .any(|column| column.eq_ignore_ascii_case(name))
4057        {
4058            return find_column_value_vec_f32(columns, values, name);
4059        }
4060    }
4061    Err(RedDBError::Query(format!(
4062        "required vector column '{}' not found in INSERT",
4063        names.join("' or '")
4064    )))
4065}
4066
4067/// Extract remaining properties (all columns not in the exclusion list).
4068fn extract_remaining_properties(
4069    columns: &[String],
4070    values: &[Value],
4071    exclude: &[&str],
4072) -> Vec<(String, Value)> {
4073    columns
4074        .iter()
4075        .zip(values.iter())
4076        .filter(|(col, _)| !exclude.iter().any(|e| col.eq_ignore_ascii_case(e)))
4077        .map(|(col, val)| (col.clone(), val.clone()))
4078        .collect()
4079}
4080
4081fn validate_timeseries_insert_columns(columns: &[String]) -> RedDBResult<()> {
4082    let mut invalid = Vec::new();
4083    for column in columns {
4084        if !is_timeseries_insert_column(column) && resolve_sql_ttl_metadata_key(column).is_none() {
4085            invalid.push(column.clone());
4086        }
4087    }
4088
4089    if invalid.is_empty() {
4090        Ok(())
4091    } else {
4092        Err(RedDBError::Query(format!(
4093            "timeseries INSERT only accepts metric, value, tags, timestamp, timestamp_ns, or time columns; got {}",
4094            invalid.join(", ")
4095        )))
4096    }
4097}
4098
4099fn is_timeseries_insert_column(column: &str) -> bool {
4100    matches!(
4101        column.to_ascii_lowercase().as_str(),
4102        "metric"
4103            | "value"
4104            | "tags"
4105            | "timestamp"
4106            | "timestamp_ns"
4107            | "time"
4108            // Analytics-event extension (#577): an analytics row carries
4109            // an `event_name` + JSON `payload`. The payload is validated
4110            // against the AnalyticsSchemaRegistry inside
4111            // `insert_timeseries_point` before the row lands.
4112            | "event_name"
4113            | "payload"
4114    )
4115}
4116
4117fn find_timeseries_timestamp_ns(columns: &[String], values: &[Value]) -> RedDBResult<Option<u64>> {
4118    let mut found = None;
4119
4120    for alias in ["timestamp_ns", "timestamp", "time"] {
4121        for (index, column) in columns.iter().enumerate() {
4122            if !column.eq_ignore_ascii_case(alias) {
4123                continue;
4124            }
4125
4126            if found.is_some() {
4127                return Err(RedDBError::Query(
4128                    "timeseries INSERT accepts only one timestamp column".to_string(),
4129                ));
4130            }
4131
4132            found = Some(coerce_value_to_non_negative_u64(&values[index], alias)?);
4133        }
4134    }
4135
4136    Ok(found)
4137}
4138
4139fn find_timeseries_tags(
4140    columns: &[String],
4141    values: &[Value],
4142) -> RedDBResult<std::collections::HashMap<String, String>> {
4143    for (index, column) in columns.iter().enumerate() {
4144        if column.eq_ignore_ascii_case("tags") {
4145            return parse_timeseries_tags(&values[index]);
4146        }
4147    }
4148    Ok(std::collections::HashMap::new())
4149}
4150
4151fn parse_timeseries_tags(value: &Value) -> RedDBResult<std::collections::HashMap<String, String>> {
4152    match value {
4153        Value::Null => Ok(std::collections::HashMap::new()),
4154        Value::Json(bytes) => parse_timeseries_tags_json(bytes),
4155        Value::Text(text) => parse_timeseries_tags_json(text.as_bytes()),
4156        other => Err(RedDBError::Query(format!(
4157            "timeseries tags must be a JSON object or JSON text, got {other:?}"
4158        ))),
4159    }
4160}
4161
4162fn parse_timeseries_tags_json(
4163    bytes: &[u8],
4164) -> RedDBResult<std::collections::HashMap<String, String>> {
4165    let json: crate::json::Value = crate::json::from_slice(bytes)
4166        .map_err(|err| RedDBError::Query(format!("timeseries tags must be valid JSON: {err}")))?;
4167
4168    let object = match json {
4169        crate::json::Value::Object(object) => object,
4170        other => {
4171            return Err(RedDBError::Query(format!(
4172                "timeseries tags must be a JSON object, got {other:?}"
4173            )))
4174        }
4175    };
4176
4177    let mut tags = std::collections::HashMap::with_capacity(object.len());
4178    for (key, value) in object {
4179        tags.insert(key, json_tag_value_to_string(&value));
4180    }
4181    Ok(tags)
4182}
4183
4184/// Encode a tag value for storage so the original JSON type can be
4185/// recovered on read (issue #543).
4186///
4187/// Time-series tags are stored as `HashMap<String, String>` on the
4188/// physical record (see [`crate::storage::TimeSeriesData`]) so that
4189/// the segment codec, WAL and gRPC mirrors don't need a new value
4190/// variant. To preserve the original JSON type across that
4191/// string-only channel we prepend the
4192/// [`crate::runtime::query_exec::TIMESERIES_TAG_JSON_PREFIX`] marker
4193/// and serialize the value as compact JSON text. The read paths
4194/// (`timeseries_tags_json_value` / `timeseries_tags_value`) detect
4195/// the marker, parse the suffix, and recover a real JSON value.
4196/// Tags written through other channels (Prometheus remote write,
4197/// metrics handlers, legacy on-disk data) lack the marker and are
4198/// returned as `JsonValue::String(raw)` exactly as before.
4199fn json_tag_value_to_string(value: &crate::json::Value) -> String {
4200    let mut buf = String::with_capacity(value.to_string_compact().len() + 1);
4201    buf.push(crate::runtime::query_exec::TIMESERIES_TAG_JSON_PREFIX);
4202    buf.push_str(&value.to_string_compact());
4203    buf
4204}
4205
4206fn coerce_value_to_non_negative_u64(value: &Value, column: &str) -> RedDBResult<u64> {
4207    match value {
4208        Value::UnsignedInteger(value) => Ok(*value),
4209        Value::Integer(value) if *value >= 0 => Ok(*value as u64),
4210        Value::Float(value) if *value >= 0.0 => Ok(*value as u64),
4211        Value::Text(value) => value.parse::<u64>().map_err(|_| {
4212            RedDBError::Query(format!(
4213                "column '{column}' expected a non-negative integer timestamp, got '{value}'"
4214            ))
4215        }),
4216        other => Err(RedDBError::Query(format!(
4217            "column '{column}' expected a non-negative integer timestamp, got {other:?}"
4218        ))),
4219    }
4220}
4221
4222fn current_unix_ns() -> u64 {
4223    std::time::SystemTime::now()
4224        .duration_since(std::time::UNIX_EPOCH)
4225        .unwrap_or_default()
4226        .as_nanos()
4227        .min(u128::from(u64::MAX)) as u64
4228}
4229
4230fn metadata_value_to_json(value: &MetadataValue) -> crate::json::Value {
4231    use crate::json::{Map, Value as JV};
4232    match value {
4233        MetadataValue::Null => JV::Null,
4234        MetadataValue::Bool(value) => JV::Bool(*value),
4235        MetadataValue::Int(value) => JV::Number(*value as f64),
4236        MetadataValue::Float(value) => JV::Number(*value),
4237        MetadataValue::String(value) => JV::String(value.clone()),
4238        MetadataValue::Bytes(value) => JV::Array(
4239            value
4240                .iter()
4241                .map(|value| JV::Number(*value as f64))
4242                .collect(),
4243        ),
4244        MetadataValue::Timestamp(value) => JV::Number(*value as f64),
4245        MetadataValue::Array(values) => {
4246            JV::Array(values.iter().map(metadata_value_to_json).collect())
4247        }
4248        MetadataValue::Object(object) => {
4249            let entries = object
4250                .iter()
4251                .map(|(key, value)| (key.clone(), metadata_value_to_json(value)))
4252                .collect();
4253            JV::Object(entries)
4254        }
4255        MetadataValue::Geo { lat, lon } => {
4256            let mut object = Map::new();
4257            object.insert("lat".to_string(), JV::Number(*lat));
4258            object.insert("lon".to_string(), JV::Number(*lon));
4259            JV::Object(object)
4260        }
4261        MetadataValue::Reference(target) => {
4262            let mut object = Map::new();
4263            object.insert(
4264                "collection".to_string(),
4265                JV::String(target.collection().to_string()),
4266            );
4267            object.insert(
4268                "entity_id".to_string(),
4269                JV::Number(target.entity_id().raw() as f64),
4270            );
4271            JV::Object(object)
4272        }
4273        MetadataValue::References(values) => {
4274            let refs = values
4275                .iter()
4276                .map(|target| {
4277                    let mut object = Map::new();
4278                    object.insert(
4279                        "collection".to_string(),
4280                        JV::String(target.collection().to_string()),
4281                    );
4282                    object.insert(
4283                        "entity_id".to_string(),
4284                        JV::Number(target.entity_id().raw() as f64),
4285                    );
4286                    JV::Object(object)
4287                })
4288                .collect();
4289            JV::Array(refs)
4290        }
4291    }
4292}
4293
4294fn storage_value_to_metadata_value(value: &Value) -> MetadataValue {
4295    match value {
4296        Value::Null => MetadataValue::Null,
4297        Value::Boolean(value) => MetadataValue::Bool(*value),
4298        Value::Integer(value) => MetadataValue::Int(*value),
4299        Value::UnsignedInteger(value) => metadata_u64_to_value(*value),
4300        Value::Float(value) => MetadataValue::Float(*value),
4301        Value::Text(value) => MetadataValue::String(value.to_string()),
4302        Value::Blob(value) => MetadataValue::Bytes(value.clone()),
4303        Value::Timestamp(value) => {
4304            if *value >= 0 {
4305                metadata_u64_to_value(*value as u64)
4306            } else {
4307                MetadataValue::Int(*value)
4308            }
4309        }
4310        Value::TimestampMs(value) => {
4311            if *value >= 0 {
4312                metadata_u64_to_value(*value as u64)
4313            } else {
4314                MetadataValue::Int(*value)
4315            }
4316        }
4317        Value::Json(value) => MetadataValue::String(String::from_utf8_lossy(value).into_owned()),
4318        Value::Uuid(value) => MetadataValue::String(format!("{value:?}")),
4319        Value::Date(value) => MetadataValue::String(value.to_string()),
4320        Value::Time(value) => MetadataValue::String(value.to_string()),
4321        Value::Decimal(value) => MetadataValue::String(value.to_string()),
4322        Value::Ipv4(value) => MetadataValue::String(format!(
4323            "{}.{}.{}.{}",
4324            (value >> 24) & 0xFF,
4325            (value >> 16) & 0xFF,
4326            (value >> 8) & 0xFF,
4327            value & 0xFF
4328        )),
4329        Value::Port(value) => MetadataValue::Int(i64::from(*value)),
4330        Value::Latitude(value) => MetadataValue::Float(*value as f64 / 1_000_000.0),
4331        Value::Longitude(value) => MetadataValue::Float(*value as f64 / 1_000_000.0),
4332        Value::GeoPoint(lat, lon) => MetadataValue::Geo {
4333            lat: *lat as f64 / 1_000_000.0,
4334            lon: *lon as f64 / 1_000_000.0,
4335        },
4336        Value::BigInt(value) => MetadataValue::String(value.to_string()),
4337        Value::TableRef(value) => MetadataValue::String(value.clone()),
4338        Value::PageRef(value) => MetadataValue::Int(*value as i64),
4339        Value::Password(value) => MetadataValue::String(value.clone()),
4340        Value::Array(values) => {
4341            MetadataValue::Array(values.iter().map(storage_value_to_metadata_value).collect())
4342        }
4343        _ => MetadataValue::String(value.to_string()),
4344    }
4345}
4346
4347fn sql_literal_to_metadata_value(field: &str, value: &Value) -> RedDBResult<MetadataValue> {
4348    match value {
4349        Value::Null => Ok(MetadataValue::Null),
4350        Value::Integer(value) if *value >= 0 => Ok(metadata_u64_to_value(*value as u64)),
4351        Value::Integer(_) => Err(RedDBError::Query(format!(
4352            "column '{field}' must be non-negative for TTL metadata"
4353        ))),
4354        Value::UnsignedInteger(value) => Ok(metadata_u64_to_value(*value)),
4355        Value::Float(value) if value.is_finite() => {
4356            if value.fract().abs() >= f64::EPSILON {
4357                return Err(RedDBError::Query(format!(
4358                    "column '{field}' must be an integer (TTL metadata must be an integer)"
4359                )));
4360            }
4361            if *value < 0.0 {
4362                return Err(RedDBError::Query(format!(
4363                    "column '{field}' must be non-negative for TTL metadata"
4364                )));
4365            }
4366            if *value > u64::MAX as f64 {
4367                return Err(RedDBError::Query(format!(
4368                    "column '{field}' value is too large"
4369                )));
4370            }
4371            Ok(metadata_u64_to_value(*value as u64))
4372        }
4373        Value::Float(_) => Err(RedDBError::Query(format!(
4374            "column '{field}' must be a finite number"
4375        ))),
4376        Value::Text(value) => {
4377            let value = value.trim();
4378            if let Ok(value) = value.parse::<u64>() {
4379                Ok(metadata_u64_to_value(value))
4380            } else if let Ok(value) = value.parse::<i64>() {
4381                if value < 0 {
4382                    return Err(RedDBError::Query(format!(
4383                        "column '{field}' must be non-negative for TTL metadata"
4384                    )));
4385                }
4386                Ok(metadata_u64_to_value(value as u64))
4387            } else if let Ok(value) = value.parse::<f64>() {
4388                if !value.is_finite() {
4389                    return Err(RedDBError::Query(format!(
4390                        "column '{field}' must be a finite number"
4391                    )));
4392                }
4393                if value.fract().abs() >= f64::EPSILON {
4394                    return Err(RedDBError::Query(format!(
4395                        "column '{field}' must be an integer (TTL metadata must be an integer)"
4396                    )));
4397                }
4398                if value < 0.0 {
4399                    return Err(RedDBError::Query(format!(
4400                        "column '{field}' must be non-negative for TTL metadata"
4401                    )));
4402                }
4403                if value > u64::MAX as f64 {
4404                    return Err(RedDBError::Query(format!(
4405                        "column '{field}' value is too large"
4406                    )));
4407                }
4408                Ok(metadata_u64_to_value(value as u64))
4409            } else {
4410                Err(RedDBError::Query(format!(
4411                    "column '{field}' expects a numeric value for TTL metadata"
4412                )))
4413            }
4414        }
4415        _ => Err(RedDBError::Query(format!(
4416            "column '{field}' expects a numeric value for TTL metadata"
4417        ))),
4418    }
4419}
4420
4421fn metadata_u64_to_value(value: u64) -> MetadataValue {
4422    if value <= i64::MAX as u64 {
4423        MetadataValue::Int(value as i64)
4424    } else {
4425        MetadataValue::Timestamp(value)
4426    }
4427}
4428
4429/// Phase 2 PG parity: inspect a column value and return `true` when
4430/// the dotted `tail` path is already present under it. Used by the
4431/// tenant auto-fill so rows that already carry an explicit value
4432/// (bulk import, admin insert on behalf of a tenant) are not
4433/// double-stamped with the session's current_tenant().
4434fn dotted_tail_already_set(value: &Value, tail: &str) -> bool {
4435    let json = match value {
4436        Value::Null => return false,
4437        Value::Json(bytes) | Value::Blob(bytes) => {
4438            match crate::json::from_slice::<crate::json::Value>(bytes) {
4439                Ok(v) => v,
4440                Err(_) => return false,
4441            }
4442        }
4443        Value::Text(s) => {
4444            let trimmed = s.trim_start();
4445            if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
4446                return false;
4447            }
4448            match crate::json::from_str::<crate::json::Value>(s) {
4449                Ok(v) => v,
4450                Err(_) => return false,
4451            }
4452        }
4453        _ => return false,
4454    };
4455    let mut cursor = &json;
4456    for seg in tail.split('.') {
4457        match cursor {
4458            crate::json::Value::Object(map) => match map.iter().find(|(k, _)| *k == seg) {
4459                Some((_, v)) => cursor = v,
4460                None => return false,
4461            },
4462            _ => return false,
4463        }
4464    }
4465    !matches!(cursor, crate::json::Value::Null)
4466}
4467
4468/// Phase 2 PG parity: take a column value (possibly Null / Text /
4469/// Json) and return a `Value::Json` with the dotted `tail` path set
4470/// to `tenant_id`. Preserves every pre-existing key.
4471///
4472/// Accepts:
4473/// * `Value::Null`  → fresh `{tail: tenant_id}` object
4474/// * `Value::Json(bytes)` → parse, navigate / create path, re-serialize
4475/// * `Value::text(s)` if `s` is valid JSON → same as Json
4476/// * anything else → error (user supplied a scalar where we need
4477///   a JSON container)
4478fn merge_dotted_tenant(current: Value, tail: &str, tenant_id: &str) -> RedDBResult<Value> {
4479    let mut root = match current {
4480        Value::Null => crate::json::Value::Object(Default::default()),
4481        Value::Json(bytes) | Value::Blob(bytes) => {
4482            crate::json::from_slice(&bytes).map_err(|err| {
4483                RedDBError::Query(format!(
4484                    "tenant auto-fill: root column is not valid JSON ({err})"
4485                ))
4486            })?
4487        }
4488        Value::Text(s) => {
4489            if s.trim().is_empty() {
4490                crate::json::Value::Object(Default::default())
4491            } else {
4492                crate::json::from_str::<crate::json::Value>(&s).map_err(|err| {
4493                    RedDBError::Query(format!(
4494                        "tenant auto-fill: text root is not valid JSON ({err})"
4495                    ))
4496                })?
4497            }
4498        }
4499        other => {
4500            return Err(RedDBError::Query(format!(
4501                "tenant auto-fill: root column must be JSON / NULL, got {other:?}"
4502            )));
4503        }
4504    };
4505
4506    // Navigate path segments, creating intermediate objects on demand.
4507    let segments: Vec<&str> = tail.split('.').collect();
4508    let mut cursor: &mut crate::json::Value = &mut root;
4509    for (i, seg) in segments.iter().enumerate() {
4510        let is_last = i + 1 == segments.len();
4511        let map = match cursor {
4512            crate::json::Value::Object(m) => m,
4513            _ => {
4514                return Err(RedDBError::Query(format!(
4515                    "tenant auto-fill: segment '{seg}' is not inside an object"
4516                )));
4517            }
4518        };
4519        if is_last {
4520            map.insert(
4521                seg.to_string(),
4522                crate::json::Value::String(tenant_id.to_string()),
4523            );
4524            break;
4525        }
4526        cursor = map
4527            .entry(seg.to_string())
4528            .or_insert_with(|| crate::json::Value::Object(Default::default()));
4529    }
4530
4531    let bytes = crate::json::to_vec(&root).map_err(|err| {
4532        RedDBError::Query(format!(
4533            "tenant auto-fill: failed to re-serialize JSON ({err})"
4534        ))
4535    })?;
4536    Ok(Value::Json(bytes))
4537}
4538
4539#[cfg(test)]
4540mod tests {
4541    use crate::storage::schema::Value;
4542    use crate::storage::wal::{WalReader, WalRecord};
4543    use crate::storage::{DeployProfile, StoragePackaging, StorageProfileSelection};
4544    use crate::{RedDBOptions, RedDBRuntime};
4545    use std::path::Path;
4546
4547    fn persistent_operational_options(path: &Path) -> RedDBOptions {
4548        RedDBOptions::persistent(path)
4549            .with_storage_profile(StorageProfileSelection {
4550                deploy_profile: DeployProfile::Embedded,
4551                packaging: StoragePackaging::OperationalDirectory,
4552                replica_count: 0,
4553                managed_backup: false,
4554                wal_retention: false,
4555            })
4556            .unwrap()
4557    }
4558
4559    fn store_commit_batches(wal_path: &Path) -> Vec<Vec<Vec<u8>>> {
4560        WalReader::open(wal_path)
4561            .expect("wal opens")
4562            .iter()
4563            .map(|record| record.expect("wal record decodes").1)
4564            .filter_map(|record| match record {
4565                WalRecord::TxCommitBatch { actions, .. } => Some(actions),
4566                _ => None,
4567            })
4568            .collect()
4569    }
4570
4571    fn action_contains_text(action: &[u8], needle: &str) -> bool {
4572        action
4573            .windows(needle.len())
4574            .any(|window| window == needle.as_bytes())
4575    }
4576
4577    fn claim_metric_count(
4578        snapshot: &crate::runtime::ClaimTelemetrySnapshot,
4579        metric: &str,
4580        collection: &str,
4581        model: &str,
4582    ) -> u64 {
4583        let rows = match metric {
4584            "attempts" => &snapshot.attempts,
4585            "successful" => &snapshot.successful,
4586            "misses" => &snapshot.misses,
4587            "skipped_locked" => &snapshot.skipped_locked,
4588            other => panic!("unknown claim metric {other}"),
4589        };
4590        rows.iter()
4591            .find(|((actual_collection, actual_model), _)| {
4592                actual_collection == collection && actual_model == model
4593            })
4594            .map(|(_, count)| *count)
4595            .unwrap_or(0)
4596    }
4597
4598    fn assert_statement_writes_collections_in_one_new_wal_batch(
4599        rt: &RedDBRuntime,
4600        wal_path: &Path,
4601        statement: &str,
4602        source: &str,
4603        event_queue: &str,
4604    ) {
4605        let before_batches = store_commit_batches(wal_path).len();
4606
4607        rt.execute_query(statement).unwrap();
4608
4609        let batches = store_commit_batches(wal_path);
4610        let statement_batches = &batches[before_batches..];
4611        let source_batch = statement_batches
4612            .iter()
4613            .position(|actions| {
4614                actions.iter().any(|action| {
4615                    action_contains_text(action, source)
4616                        && !action_contains_text(action, event_queue)
4617                })
4618            })
4619            .expect("source collection write batch is present");
4620        let event_batch = statement_batches
4621            .iter()
4622            .position(|actions| {
4623                actions
4624                    .iter()
4625                    .any(|action| action_contains_text(action, event_queue))
4626            })
4627            .expect("event queue write batch is present");
4628
4629        assert_eq!(
4630            source_batch, event_batch,
4631            "WITH EVENTS must persist the source write and queue event in the same WAL batch"
4632        );
4633    }
4634
4635    #[test]
4636    fn with_events_autocommit_persists_mutation_and_event_in_one_wal_batch() {
4637        let dir = tempfile::tempdir().unwrap();
4638        let db_path = dir.path().join("events_dual_write.rdb");
4639        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
4640        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
4641
4642        rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
4643            .unwrap();
4644        assert_statement_writes_collections_in_one_new_wal_batch(
4645            &rt,
4646            &wal_path,
4647            "INSERT INTO users (id, email) VALUES (1, 'a@example.test')",
4648            "users",
4649            "users_events",
4650        );
4651    }
4652
4653    #[test]
4654    fn with_events_autocommit_update_persists_mutation_and_event_in_one_wal_batch() {
4655        let dir = tempfile::tempdir().unwrap();
4656        let db_path = dir.path().join("events_update_atomic.rdb");
4657        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
4658        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
4659
4660        rt.execute_query(
4661            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (UPDATE) TO user_updates",
4662        )
4663        .unwrap();
4664        rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
4665            .unwrap();
4666
4667        assert_statement_writes_collections_in_one_new_wal_batch(
4668            &rt,
4669            &wal_path,
4670            "UPDATE users SET email = 'b@example.test' WHERE id = 1",
4671            "users",
4672            "user_updates",
4673        );
4674    }
4675
4676    #[test]
4677    fn with_events_autocommit_delete_persists_mutation_and_event_in_one_wal_batch() {
4678        let dir = tempfile::tempdir().unwrap();
4679        let db_path = dir.path().join("events_delete_atomic.rdb");
4680        let wal_path = reddb_file::layout::unified_wal_path(&db_path);
4681        let rt = RedDBRuntime::with_options(persistent_operational_options(&db_path)).unwrap();
4682
4683        rt.execute_query(
4684            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (DELETE) TO user_deletes",
4685        )
4686        .unwrap();
4687        rt.execute_query("INSERT INTO users (id, email) VALUES (1, 'a@example.test')")
4688            .unwrap();
4689
4690        assert_statement_writes_collections_in_one_new_wal_batch(
4691            &rt,
4692            &wal_path,
4693            "DELETE FROM users WHERE id = 1",
4694            "users",
4695            "user_deletes",
4696        );
4697    }
4698
4699    #[test]
4700    fn update_where_id_in_with_hash_index_updates_expected_rows() {
4701        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4702        rt.execute_query("CREATE TABLE users (id INT, score INT)")
4703            .unwrap();
4704        for id in 0..5 {
4705            rt.execute_query(&format!("INSERT INTO users (id, score) VALUES ({id}, 0)"))
4706                .unwrap();
4707        }
4708        rt.execute_query("CREATE INDEX idx_id ON users (id) USING HASH")
4709            .unwrap();
4710
4711        let updated = rt
4712            .execute_query("UPDATE users SET score = 42 WHERE id IN (1,3,4)")
4713            .unwrap();
4714        assert_eq!(updated.affected_rows, 3);
4715
4716        let selected = rt
4717            .execute_query("SELECT id, score FROM users ORDER BY id")
4718            .unwrap();
4719        let scores: Vec<(i64, i64)> = selected
4720            .result
4721            .records
4722            .iter()
4723            .map(|record| {
4724                let id = match record.get("id").unwrap() {
4725                    Value::Integer(value) => *value,
4726                    other => panic!("expected integer id, got {other:?}"),
4727                };
4728                let score = match record.get("score").unwrap() {
4729                    Value::Integer(value) => *value,
4730                    other => panic!("expected integer score, got {other:?}"),
4731                };
4732                (id, score)
4733            })
4734            .collect();
4735        assert_eq!(scores, vec![(0, 0), (1, 42), (2, 0), (3, 42), (4, 42)]);
4736    }
4737
4738    /// Drives UPDATE through the shared `DmlTargetScan` module — the
4739    /// same code path DELETE uses (#51, #52). Exercises the indexed
4740    /// equality fast-path (WHERE id = N with a HASH index), the
4741    /// unindexed range scan (WHERE score > N), and the no-WHERE
4742    /// full-scan branch to confirm the extracted "find target rows"
4743    /// loop preserves affected-row counts and the resulting row state.
4744    #[test]
4745    fn update_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
4746        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4747        rt.execute_query("CREATE TABLE items (id INT, score INT)")
4748            .unwrap();
4749        for id in 0..5 {
4750            rt.execute_query(&format!(
4751                "INSERT INTO items (id, score) VALUES ({id}, {})",
4752                id * 10
4753            ))
4754            .unwrap();
4755        }
4756        rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
4757            .unwrap();
4758
4759        // Indexed equality UPDATE — hits the hash fast-path inside
4760        // DmlTargetScan::find_target_ids. id=2 has score=20, drop it
4761        // below the score>25 cutoff so the next assertion stays clean.
4762        let updated_one = rt
4763            .execute_query("UPDATE items SET score = 5 WHERE id = 2")
4764            .unwrap();
4765        assert_eq!(updated_one.affected_rows, 1);
4766
4767        // Unindexed scan UPDATE — bumps everyone with score > 25,
4768        // i.e. ids 3 and 4 (scores 30, 40). Goes through the
4769        // zoned/full-scan branch.
4770        let updated_many = rt
4771            .execute_query("UPDATE items SET score = 7 WHERE score > 25")
4772            .unwrap();
4773        assert_eq!(updated_many.affected_rows, 2);
4774
4775        let snapshot = rt
4776            .execute_query("SELECT id, score FROM items ORDER BY id")
4777            .unwrap();
4778        let pairs: Vec<(i64, i64)> = snapshot
4779            .result
4780            .records
4781            .iter()
4782            .map(|record| {
4783                let id = match record.get("id").unwrap() {
4784                    Value::Integer(value) => *value,
4785                    other => panic!("expected integer id, got {other:?}"),
4786                };
4787                let score = match record.get("score").unwrap() {
4788                    Value::Integer(value) => *value,
4789                    other => panic!("expected integer score, got {other:?}"),
4790                };
4791                (id, score)
4792            })
4793            .collect();
4794        assert_eq!(pairs, vec![(0, 0), (1, 10), (2, 5), (3, 7), (4, 7)]);
4795
4796        // Full-scan UPDATE with no WHERE rewrites every remaining row.
4797        let updated_all = rt.execute_query("UPDATE items SET score = 1").unwrap();
4798        assert_eq!(updated_all.affected_rows, 5);
4799        let after = rt
4800            .execute_query("SELECT score FROM items ORDER BY id")
4801            .unwrap();
4802        let scores: Vec<i64> = after
4803            .result
4804            .records
4805            .iter()
4806            .map(|record| match record.get("score").unwrap() {
4807                Value::Integer(value) => *value,
4808                other => panic!("expected integer score, got {other:?}"),
4809            })
4810            .collect();
4811        assert_eq!(scores, vec![1, 1, 1, 1, 1]);
4812    }
4813
4814    /// Drives DELETE through the new `DmlTargetScan` module. Exercises
4815    /// both the index fast-path (WHERE id = N with a HASH index) and
4816    /// the unindexed scan path (WHERE score > N) to confirm the
4817    /// extracted "find target rows" loop preserves the affected-row
4818    /// count and which rows survive.
4819    #[test]
4820    fn delete_routes_through_dml_target_scan_for_indexed_and_scan_paths() {
4821        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4822        rt.execute_query("CREATE TABLE items (id INT, score INT)")
4823            .unwrap();
4824        for id in 0..5 {
4825            rt.execute_query(&format!(
4826                "INSERT INTO items (id, score) VALUES ({id}, {})",
4827                id * 10
4828            ))
4829            .unwrap();
4830        }
4831        rt.execute_query("CREATE INDEX idx_items_id ON items (id) USING HASH")
4832            .unwrap();
4833
4834        // Indexed equality DELETE — hits the hash fast-path inside
4835        // DmlTargetScan::find_target_ids.
4836        let deleted_one = rt.execute_query("DELETE FROM items WHERE id = 2").unwrap();
4837        assert_eq!(deleted_one.affected_rows, 1);
4838
4839        // Unindexed scan DELETE — drops everyone with score > 25,
4840        // i.e. ids 3 and 4 (scores 30, 40). Goes through the
4841        // zoned/full-scan branch.
4842        let deleted_many = rt
4843            .execute_query("DELETE FROM items WHERE score > 25")
4844            .unwrap();
4845        assert_eq!(deleted_many.affected_rows, 2);
4846
4847        let surviving = rt
4848            .execute_query("SELECT id FROM items ORDER BY id")
4849            .unwrap();
4850        let ids: Vec<i64> = surviving
4851            .result
4852            .records
4853            .iter()
4854            .map(|record| match record.get("id").unwrap() {
4855                Value::Integer(value) => *value,
4856                other => panic!("expected integer id, got {other:?}"),
4857            })
4858            .collect();
4859        assert_eq!(ids, vec![0, 1]);
4860
4861        // Sanity: full-scan DELETE with no WHERE clears the rest.
4862        let deleted_rest = rt.execute_query("DELETE FROM items").unwrap();
4863        assert_eq!(deleted_rest.affected_rows, 2);
4864        let empty = rt.execute_query("SELECT id FROM items").unwrap();
4865        assert!(empty.result.records.is_empty());
4866    }
4867
4868    /// CollectionContract gate (#49 + #50): APPEND ONLY tables accept
4869    /// INSERT but reject UPDATE and DELETE with the documented
4870    /// operator-facing error strings. Drives all three DML verbs so
4871    /// the centralized gate is exercised end-to-end.
4872    #[test]
4873    fn collection_contract_gate_blocks_update_and_delete_on_append_only() {
4874        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4875        rt.execute_query("CREATE TABLE events (id INT, payload TEXT) APPEND ONLY")
4876            .unwrap();
4877
4878        // INSERT must succeed — APPEND ONLY exists precisely to allow
4879        // appends. The gate should be a no-op for INSERT.
4880        let inserted = rt
4881            .execute_query("INSERT INTO events (id, payload) VALUES (1, 'hello')")
4882            .unwrap();
4883        assert_eq!(inserted.affected_rows, 1);
4884
4885        // UPDATE is rejected with the gate's UPDATE-specific message.
4886        let update_err = rt
4887            .execute_query("UPDATE events SET payload = 'mut' WHERE id = 1")
4888            .unwrap_err();
4889        let msg = format!("{update_err}");
4890        assert!(
4891            msg.contains("APPEND ONLY") && msg.contains("UPDATE is rejected"),
4892            "expected UPDATE rejection message, got: {msg}"
4893        );
4894
4895        // DELETE is rejected with the gate's DELETE-specific message.
4896        let delete_err = rt
4897            .execute_query("DELETE FROM events WHERE id = 1")
4898            .unwrap_err();
4899        let msg = format!("{delete_err}");
4900        assert!(
4901            msg.contains("APPEND ONLY") && msg.contains("DELETE is rejected"),
4902            "expected DELETE rejection message, got: {msg}"
4903        );
4904
4905        // Row should still be present — neither rejected mutation
4906        // touched storage.
4907        let surviving = rt.execute_query("SELECT id FROM events").unwrap();
4908        assert_eq!(surviving.result.records.len(), 1);
4909    }
4910
4911    /// CollectionContract gate: tables without an APPEND ONLY contract
4912    /// permit INSERT, UPDATE, and DELETE — the gate's default branch
4913    /// is a true pass-through, not an accidental block.
4914    #[test]
4915    fn collection_contract_gate_allows_all_verbs_on_unrestricted_table() {
4916        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4917        rt.execute_query("CREATE TABLE notes (id INT, body TEXT)")
4918            .unwrap();
4919
4920        rt.execute_query("INSERT INTO notes (id, body) VALUES (1, 'a')")
4921            .unwrap();
4922        let updated = rt
4923            .execute_query("UPDATE notes SET body = 'b' WHERE id = 1")
4924            .unwrap();
4925        assert_eq!(updated.affected_rows, 1);
4926        let deleted = rt.execute_query("DELETE FROM notes WHERE id = 1").unwrap();
4927        assert_eq!(deleted.affected_rows, 1);
4928    }
4929
4930    #[test]
4931    fn insert_into_event_enabled_table_emits_event_to_configured_queue() {
4932        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4933        rt.execute_query(
4934            "CREATE TABLE users (id INT, email TEXT) WITH EVENTS (INSERT) TO audit_log",
4935        )
4936        .unwrap();
4937
4938        let inserted = rt
4939            .execute_query("INSERT INTO users (id, email) VALUES (7, 'a@example.com')")
4940            .unwrap();
4941        assert_eq!(inserted.affected_rows, 1);
4942
4943        let events = queue_payloads(&rt, "audit_log");
4944        assert_eq!(events.len(), 1);
4945        let event = events[0].as_object().expect("event payload object");
4946        assert!(event
4947            .get("event_id")
4948            .and_then(crate::json::Value::as_str)
4949            .is_some_and(|value| !value.is_empty()));
4950        assert_eq!(
4951            event.get("op").and_then(crate::json::Value::as_str),
4952            Some("insert")
4953        );
4954        assert_eq!(
4955            event.get("collection").and_then(crate::json::Value::as_str),
4956            Some("users")
4957        );
4958        assert_eq!(
4959            event.get("id").and_then(crate::json::Value::as_u64),
4960            Some(7)
4961        );
4962        assert!(event
4963            .get("ts")
4964            .and_then(crate::json::Value::as_u64)
4965            .is_some());
4966        assert!(event
4967            .get("lsn")
4968            .and_then(crate::json::Value::as_u64)
4969            .is_some());
4970        assert!(matches!(
4971            event.get("tenant"),
4972            Some(crate::json::Value::Null)
4973        ));
4974        assert!(matches!(
4975            event.get("before"),
4976            Some(crate::json::Value::Null)
4977        ));
4978        let after = event
4979            .get("after")
4980            .and_then(crate::json::Value::as_object)
4981            .expect("after object");
4982        assert_eq!(
4983            after.get("id").and_then(crate::json::Value::as_u64),
4984            Some(7)
4985        );
4986        assert_eq!(
4987            after.get("email").and_then(crate::json::Value::as_str),
4988            Some("a@example.com")
4989        );
4990    }
4991
4992    #[test]
4993    fn multi_row_insert_emits_one_insert_event_per_row_in_order() {
4994        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
4995        rt.execute_query("CREATE TABLE users (id INT, email TEXT) WITH EVENTS")
4996            .unwrap();
4997
4998        rt.execute_query(
4999            "INSERT INTO users (id, email) VALUES (1, 'a@example.com'), (2, 'b@example.com')",
5000        )
5001        .unwrap();
5002
5003        let events = queue_payloads(&rt, "users_events");
5004        assert_eq!(events.len(), 2);
5005        let mut previous_lsn = 0;
5006        for (event, expected_id) in events.iter().zip([1_u64, 2]) {
5007            let object = event.as_object().expect("event payload object");
5008            assert_eq!(
5009                object.get("op").and_then(crate::json::Value::as_str),
5010                Some("insert")
5011            );
5012            assert_eq!(
5013                object.get("id").and_then(crate::json::Value::as_u64),
5014                Some(expected_id)
5015            );
5016            let lsn = object
5017                .get("lsn")
5018                .and_then(crate::json::Value::as_u64)
5019                .expect("event lsn");
5020            assert!(
5021                lsn > previous_lsn,
5022                "event LSNs should increase in row order"
5023            );
5024            previous_lsn = lsn;
5025            let after = object
5026                .get("after")
5027                .and_then(crate::json::Value::as_object)
5028                .expect("after object");
5029            assert_eq!(
5030                after.get("id").and_then(crate::json::Value::as_u64),
5031                Some(expected_id)
5032            );
5033        }
5034    }
5035
5036    #[test]
5037    fn claim_metrics_increment_skipped_locked_without_counting_miss() {
5038        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).expect("runtime");
5039        rt.execute_query("CREATE TABLE claim_metric_locked (id INT, rank INT, status TEXT)")
5040            .expect("create table");
5041        // ADR 0063: index-backed claim ordering on `rank`.
5042        rt.execute_query("CREATE INDEX idx_claim_metric_locked_rank ON claim_metric_locked (rank)")
5043            .expect("create index");
5044        rt.execute_query(
5045            "INSERT INTO claim_metric_locked (id, rank, status) VALUES \
5046             (1, 10, 'ready'), (2, 20, 'ready')",
5047        )
5048        .expect("insert rows");
5049
5050        let claim_lock = rt
5051            .inner
5052            .rmw_locks
5053            .lock_for("claim_metric_locked", "__table_claim_update__");
5054        let _guard = claim_lock.lock();
5055        let updated = rt
5056            .execute_query(
5057                "UPDATE claim_metric_locked SET status = 'claimed' WHERE status = 'ready' \
5058                 CLAIM LIMIT 2 ORDER BY rank ASC",
5059            )
5060            .expect("claim skips locked candidates");
5061
5062        assert_eq!(updated.affected_rows, 0);
5063        let snapshot = rt.claim_telemetry_snapshot();
5064        assert_eq!(
5065            claim_metric_count(&snapshot, "attempts", "claim_metric_locked", "table"),
5066            1
5067        );
5068        assert_eq!(
5069            claim_metric_count(&snapshot, "skipped_locked", "claim_metric_locked", "table"),
5070            2
5071        );
5072        assert_eq!(
5073            claim_metric_count(&snapshot, "misses", "claim_metric_locked", "table"),
5074            0
5075        );
5076        assert_eq!(
5077            snapshot.skipped_locked.len(),
5078            1,
5079            "skipped-lock labels stay bounded to collection/model"
5080        );
5081    }
5082
5083    fn queue_payloads(rt: &RedDBRuntime, queue: &str) -> Vec<crate::json::Value> {
5084        let result = rt
5085            .execute_query(&format!("QUEUE PEEK {queue} 10"))
5086            .expect("peek queue");
5087        result
5088            .result
5089            .records
5090            .iter()
5091            .map(
5092                |record| match record.get("payload").expect("payload column") {
5093                    Value::Json(bytes) => crate::json::from_slice(bytes).expect("json payload"),
5094                    other => panic!("expected JSON queue payload, got {other:?}"),
5095                },
5096            )
5097            .collect()
5098    }
5099
5100    // ── #112: auto-index user `id` on first insert ─────────────────────
5101
5102    /// First insert into a fresh collection that carries a column named
5103    /// `id` registers an implicit HASH index on `id`. Subsequent inserts
5104    /// populate it transparently, and `WHERE id = N` lookups exercise
5105    /// the hash-index fast path in `DmlTargetScan::find_target_ids`.
5106    ///
5107    /// This is the load-bearing acceptance test for #112 — without the
5108    /// hook, `find_index_for_column` returns `None` and DELETE/UPDATE
5109    /// fall through to a full segment scan (the 4× perf gap documented
5110    /// in `docs/perf/delete-sequential-2026-05-06.md`).
5111    #[test]
5112    fn auto_index_id_fires_on_first_insert() {
5113        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5114        rt.execute_query("CREATE TABLE bench_users (id INT, score INT)")
5115            .unwrap();
5116
5117        // Pre-condition: no index on `id` yet.
5118        assert!(
5119            rt.index_store_ref()
5120                .find_index_for_column("bench_users", "id")
5121                .is_none(),
5122            "freshly created collection should not have an `id` index"
5123        );
5124
5125        // Single-row INSERT — drives `MutationEngine::append_one`.
5126        rt.execute_query("INSERT INTO bench_users (id, score) VALUES (1, 10)")
5127            .unwrap();
5128
5129        // Post-condition: hash index registered on `id`.
5130        let registered = rt
5131            .index_store_ref()
5132            .find_index_for_column("bench_users", "id")
5133            .expect("auto-index hook should have registered idx_id on first insert");
5134        assert_eq!(registered.name, "idx_id");
5135        assert_eq!(registered.collection, "bench_users");
5136        assert_eq!(registered.columns, vec!["id".to_string()]);
5137        assert!(matches!(
5138            registered.method,
5139            super::super::index_store::IndexMethodKind::Hash
5140        ));
5141
5142        // Subsequent inserts populate the index; `WHERE id = N` should
5143        // resolve via the hash fast path and round-trip every row.
5144        for id in 2..=5 {
5145            rt.execute_query(&format!(
5146                "INSERT INTO bench_users (id, score) VALUES ({id}, {})",
5147                id * 10
5148            ))
5149            .unwrap();
5150        }
5151        for id in 1..=5 {
5152            let result = rt
5153                .execute_query(&format!("SELECT score FROM bench_users WHERE id = {id}"))
5154                .unwrap();
5155            assert_eq!(
5156                result.result.records.len(),
5157                1,
5158                "id={id} should match one row"
5159            );
5160        }
5161
5162        // Delete via the hash fast-path — exactly the bench scenario the
5163        // perf doc identified as the 4× regression. With the index
5164        // present, `find_target_ids` short-circuits before
5165        // `for_each_entity_zoned` runs.
5166        let deleted = rt
5167            .execute_query("DELETE FROM bench_users WHERE id = 3")
5168            .unwrap();
5169        assert_eq!(deleted.affected_rows, 1);
5170    }
5171
5172    /// Bulk INSERT (the multi-row VALUES path) drives
5173    /// `MutationEngine::append_batch`. The hook must fire there too —
5174    /// otherwise the batch entry points (gRPC binary bulk, HTTP bulk,
5175    /// wire bulk INSERT) skip auto-indexing entirely.
5176    #[test]
5177    fn auto_index_id_fires_on_first_bulk_insert() {
5178        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5179        rt.execute_query("CREATE TABLE bench_bulk (id INT, score INT)")
5180            .unwrap();
5181
5182        rt.execute_query("INSERT INTO bench_bulk (id, score) VALUES (1, 10), (2, 20), (3, 30)")
5183            .unwrap();
5184
5185        let registered = rt
5186            .index_store_ref()
5187            .find_index_for_column("bench_bulk", "id")
5188            .expect("auto-index hook should fire on first bulk insert");
5189        assert_eq!(registered.name, "idx_id");
5190
5191        // Every row populated via `index_entity_insert_batch`.
5192        for id in 1..=3 {
5193            let result = rt
5194                .execute_query(&format!("SELECT score FROM bench_bulk WHERE id = {id}"))
5195                .unwrap();
5196            assert_eq!(result.result.records.len(), 1);
5197        }
5198    }
5199
5200    /// Hook is a no-op when the row carries no `id` column. Conservative
5201    /// match (case-sensitive `id`) — `Id`, `ID`, and `rid`
5202    /// don't trigger it.
5203    #[test]
5204    fn auto_index_id_skips_when_no_id_column() {
5205        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5206        rt.execute_query("CREATE TABLE plain (uid INT, label TEXT)")
5207            .unwrap();
5208        rt.execute_query("INSERT INTO plain (uid, label) VALUES (1, 'a')")
5209            .unwrap();
5210
5211        assert!(rt
5212            .index_store_ref()
5213            .find_index_for_column("plain", "id")
5214            .is_none());
5215        assert!(rt
5216            .index_store_ref()
5217            .find_index_for_column("plain", "uid")
5218            .is_none());
5219    }
5220
5221    /// Hook only fires once per collection. If an explicit
5222    /// `CREATE INDEX ... USING BTREE` already covers `id`, the hook
5223    /// detects it via `find_index_for_column` and does NOT clobber it
5224    /// with a HASH index on the next insert.
5225    #[test]
5226    fn auto_index_id_skips_when_index_already_exists() {
5227        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5228        rt.execute_query("CREATE TABLE pre (id INT, score INT)")
5229            .unwrap();
5230        // User-declared BTREE index on `id` before any insert.
5231        rt.execute_query("CREATE INDEX user_idx ON pre (id) USING BTREE")
5232            .unwrap();
5233        rt.execute_query("INSERT INTO pre (id, score) VALUES (1, 10)")
5234            .unwrap();
5235
5236        let registered = rt
5237            .index_store_ref()
5238            .find_index_for_column("pre", "id")
5239            .expect("user index should still be there");
5240        assert_eq!(
5241            registered.name, "user_idx",
5242            "auto-index hook must not overwrite an existing index"
5243        );
5244    }
5245
5246    /// Implicit `idx_id` is reaped when the collection drops. The
5247    /// existing `execute_drop_table` walks `list_indices` and drops every
5248    /// entry — confirm the auto-created index participates.
5249    #[test]
5250    fn auto_index_id_dropped_with_collection() {
5251        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5252        rt.execute_query("CREATE TABLE ephemeral (id INT, score INT)")
5253            .unwrap();
5254        rt.execute_query("INSERT INTO ephemeral (id, score) VALUES (1, 10)")
5255            .unwrap();
5256        assert!(rt
5257            .index_store_ref()
5258            .find_index_for_column("ephemeral", "id")
5259            .is_some());
5260
5261        rt.execute_query("DROP TABLE ephemeral").unwrap();
5262
5263        assert!(
5264            rt.index_store_ref()
5265                .find_index_for_column("ephemeral", "id")
5266                .is_none(),
5267            "implicit `idx_id` must be reaped when its collection drops"
5268        );
5269    }
5270
5271    /// Opt-out via `RedDBOptions::with_auto_index_id(false)` (which
5272    /// forwards to `UnifiedStoreConfig::auto_index_id`). With the knob
5273    /// off, first insert leaves the collection without an `id` index —
5274    /// DELETE/UPDATE fall back to the scan path.
5275    #[test]
5276    fn auto_index_id_disabled_by_config() {
5277        let opts = RedDBOptions::in_memory().with_auto_index_id(false);
5278        let rt = RedDBRuntime::with_options(opts).unwrap();
5279
5280        rt.execute_query("CREATE TABLE off (id INT, score INT)")
5281            .unwrap();
5282        rt.execute_query("INSERT INTO off (id, score) VALUES (1, 10)")
5283            .unwrap();
5284
5285        assert!(
5286            rt.index_store_ref()
5287                .find_index_for_column("off", "id")
5288                .is_none(),
5289            "with auto_index_id=false, no implicit index should be created"
5290        );
5291    }
5292
5293    // ── #293: UPDATE / DELETE events ─────────────────────────────────────
5294
5295    #[test]
5296    fn update_single_row_emits_update_event() {
5297        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5298        rt.execute_query(
5299            "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO audit_log",
5300        )
5301        .unwrap();
5302        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
5303            .unwrap();
5304
5305        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
5306            .unwrap();
5307
5308        let events = queue_payloads(&rt, "audit_log");
5309        assert_eq!(events.len(), 1, "expected exactly 1 update event");
5310        let event = events[0].as_object().expect("event payload object");
5311        assert_eq!(
5312            event.get("op").and_then(crate::json::Value::as_str),
5313            Some("update")
5314        );
5315        assert_eq!(
5316            event.get("collection").and_then(crate::json::Value::as_str),
5317            Some("users")
5318        );
5319        assert!(event
5320            .get("event_id")
5321            .and_then(crate::json::Value::as_str)
5322            .is_some_and(|v| !v.is_empty()));
5323        let before = event
5324            .get("before")
5325            .and_then(crate::json::Value::as_object)
5326            .expect("before must be an object");
5327        let after = event
5328            .get("after")
5329            .and_then(crate::json::Value::as_object)
5330            .expect("after must be an object");
5331        assert_eq!(
5332            before.get("name").and_then(crate::json::Value::as_str),
5333            Some("Alice"),
5334            "before.name should be the old value"
5335        );
5336        assert_eq!(
5337            after.get("name").and_then(crate::json::Value::as_str),
5338            Some("Bob"),
5339            "after.name should be the new value"
5340        );
5341    }
5342
5343    #[test]
5344    fn update_event_only_includes_changed_fields() {
5345        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5346        rt.execute_query(
5347            "CREATE TABLE users (id INT, name TEXT, email TEXT) WITH EVENTS (UPDATE) TO evts",
5348        )
5349        .unwrap();
5350        rt.execute_query("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'a@x.com')")
5351            .unwrap();
5352
5353        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1")
5354            .unwrap();
5355
5356        let events = queue_payloads(&rt, "evts");
5357        assert_eq!(events.len(), 1);
5358        let event = events[0].as_object().unwrap();
5359        let before = event
5360            .get("before")
5361            .and_then(crate::json::Value::as_object)
5362            .unwrap();
5363        let after = event
5364            .get("after")
5365            .and_then(crate::json::Value::as_object)
5366            .unwrap();
5367        // Only changed field included.
5368        assert!(
5369            before.contains_key("name"),
5370            "before must include changed field"
5371        );
5372        assert!(
5373            after.contains_key("name"),
5374            "after must include changed field"
5375        );
5376        // Unchanged fields must not appear.
5377        assert!(
5378            !before.contains_key("email"),
5379            "before must not include unchanged email"
5380        );
5381        assert!(
5382            !after.contains_key("email"),
5383            "after must not include unchanged email"
5384        );
5385    }
5386
5387    #[test]
5388    fn multi_row_update_emits_one_event_per_row() {
5389        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5390        rt.execute_query("CREATE TABLE items (id INT, status TEXT) WITH EVENTS (UPDATE) TO evts")
5391            .unwrap();
5392        rt.execute_query(
5393            "INSERT INTO items (id, status) VALUES (1, 'new'), (2, 'new'), (3, 'new')",
5394        )
5395        .unwrap();
5396
5397        rt.execute_query("UPDATE items SET status = 'done'")
5398            .unwrap();
5399
5400        let events = queue_payloads(&rt, "evts");
5401        assert_eq!(events.len(), 3, "expected one update event per row");
5402        for event in &events {
5403            let obj = event.as_object().unwrap();
5404            assert_eq!(
5405                obj.get("op").and_then(crate::json::Value::as_str),
5406                Some("update")
5407            );
5408        }
5409    }
5410
5411    #[test]
5412    fn delete_single_row_emits_delete_event() {
5413        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5414        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (DELETE) TO del_log")
5415            .unwrap();
5416        rt.execute_query("INSERT INTO users (id, name) VALUES (42, 'Alice')")
5417            .unwrap();
5418
5419        rt.execute_query("DELETE FROM users WHERE id = 42").unwrap();
5420
5421        let events = queue_payloads(&rt, "del_log");
5422        assert_eq!(events.len(), 1);
5423        let event = events[0].as_object().expect("event payload object");
5424        assert_eq!(
5425            event.get("op").and_then(crate::json::Value::as_str),
5426            Some("delete")
5427        );
5428        assert_eq!(
5429            event.get("collection").and_then(crate::json::Value::as_str),
5430            Some("users")
5431        );
5432        assert!(event
5433            .get("event_id")
5434            .and_then(crate::json::Value::as_str)
5435            .is_some_and(|v| !v.is_empty()));
5436        let before = event
5437            .get("before")
5438            .and_then(crate::json::Value::as_object)
5439            .expect("before must be an object for delete");
5440        assert_eq!(
5441            before.get("id").and_then(crate::json::Value::as_u64),
5442            Some(42)
5443        );
5444        assert_eq!(
5445            before.get("name").and_then(crate::json::Value::as_str),
5446            Some("Alice")
5447        );
5448        assert!(matches!(event.get("after"), Some(crate::json::Value::Null)));
5449    }
5450
5451    #[test]
5452    fn multi_row_delete_emits_one_event_per_row() {
5453        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5454        rt.execute_query("CREATE TABLE items (id INT, val INT) WITH EVENTS (DELETE) TO del_log")
5455            .unwrap();
5456        rt.execute_query("INSERT INTO items (id, val) VALUES (1, 10), (2, 20), (3, 30)")
5457            .unwrap();
5458
5459        rt.execute_query("DELETE FROM items").unwrap();
5460
5461        let events = queue_payloads(&rt, "del_log");
5462        assert_eq!(events.len(), 3, "expected one delete event per deleted row");
5463        for event in &events {
5464            let obj = event.as_object().unwrap();
5465            assert_eq!(
5466                obj.get("op").and_then(crate::json::Value::as_str),
5467                Some("delete")
5468            );
5469            assert!(matches!(obj.get("after"), Some(crate::json::Value::Null)));
5470        }
5471    }
5472
5473    #[test]
5474    fn ops_filter_update_does_not_emit_on_insert_or_delete() {
5475        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5476        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS (UPDATE) TO evts")
5477            .unwrap();
5478
5479        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
5480            .unwrap();
5481        rt.execute_query("DELETE FROM users WHERE id = 1").unwrap();
5482
5483        let events = queue_payloads(&rt, "evts");
5484        assert!(
5485            events.is_empty(),
5486            "UPDATE-only filter must not emit INSERT or DELETE events"
5487        );
5488    }
5489
5490    // ── SUPPRESS EVENTS ────────────────────────────────────────────────────
5491
5492    #[test]
5493    fn suppress_events_on_insert_emits_no_events() {
5494        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5495        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
5496            .unwrap();
5497
5498        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
5499            .unwrap();
5500
5501        let events = queue_payloads(&rt, "evts");
5502        assert!(
5503            events.is_empty(),
5504            "SUPPRESS EVENTS must prevent INSERT events"
5505        );
5506    }
5507
5508    #[test]
5509    fn suppress_events_on_update_emits_no_events() {
5510        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5511        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
5512            .unwrap();
5513        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice')")
5514            .unwrap();
5515        // drain the INSERT event
5516        let _ = queue_payloads(&rt, "evts");
5517        // Force pop to drain; simpler: just check new count after UPDATE
5518        rt.execute_query("QUEUE PURGE evts").unwrap();
5519
5520        rt.execute_query("UPDATE users SET name = 'Bob' WHERE id = 1 SUPPRESS EVENTS")
5521            .unwrap();
5522
5523        let events = queue_payloads(&rt, "evts");
5524        assert!(
5525            events.is_empty(),
5526            "SUPPRESS EVENTS must prevent UPDATE events"
5527        );
5528    }
5529
5530    #[test]
5531    fn suppress_events_on_delete_emits_no_events() {
5532        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5533        rt.execute_query(
5534            "CREATE TABLE users (id INT, name TEXT) WITH EVENTS (INSERT, DELETE) TO evts",
5535        )
5536        .unwrap();
5537        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
5538            .unwrap();
5539
5540        rt.execute_query("DELETE FROM users WHERE id = 1 SUPPRESS EVENTS")
5541            .unwrap();
5542
5543        let events = queue_payloads(&rt, "evts");
5544        assert!(
5545            events.is_empty(),
5546            "SUPPRESS EVENTS must prevent DELETE events"
5547        );
5548    }
5549
5550    #[test]
5551    fn normal_insert_after_suppress_still_emits() {
5552        let rt = RedDBRuntime::with_options(RedDBOptions::in_memory()).unwrap();
5553        rt.execute_query("CREATE TABLE users (id INT, name TEXT) WITH EVENTS TO evts")
5554            .unwrap();
5555
5556        rt.execute_query("INSERT INTO users (id, name) VALUES (1, 'Alice') SUPPRESS EVENTS")
5557            .unwrap();
5558        rt.execute_query("INSERT INTO users (id, name) VALUES (2, 'Bob')")
5559            .unwrap();
5560
5561        let events = queue_payloads(&rt, "evts");
5562        assert_eq!(
5563            events.len(),
5564            1,
5565            "only the non-suppressed INSERT should emit"
5566        );
5567        assert_eq!(
5568            events[0].get("id").and_then(crate::json::Value::as_u64),
5569            Some(2)
5570        );
5571    }
5572}