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