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