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