Skip to main content

reddb_server/application/
ports_impls_entity.rs

1use std::collections::HashMap;
2
3use crate::application::collection_contract_enforcer::{
4    normalize_row_update_assignment_with_plan, CollectionContractWriteEnforcer,
5};
6use crate::application::entity::{
7    AppliedEntityMutation, CreateDocumentInput, CreateKvInput, CreateTimeSeriesPointInput,
8    RowUpdateColumnRule, RowUpdateContractPlan,
9};
10use crate::application::ttl_payload::{
11    normalize_ttl_patch_operations, parse_top_level_ttl_metadata_entries,
12};
13use crate::json::{to_vec as json_to_vec, Value as JsonValue};
14use crate::storage::unified::MetadataValue;
15use reddb_types::Value;
16
17use super::*;
18
19const TREE_METADATA_PREFIX: &str = "red.tree.";
20const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
21
22fn refresh_context_index(
23    db: &crate::storage::unified::devx::RedDB,
24    collection: &str,
25    id: crate::storage::EntityId,
26) -> RedDBResult<()> {
27    let store = db.store();
28    let Some(entity) = store.get(collection, id) else {
29        return Ok(());
30    };
31
32    store.context_index().index_entity(collection, &entity);
33    Ok(())
34}
35
36fn ensure_insert_entity_rls(
37    runtime: &RedDBRuntime,
38    collection: &str,
39    entity: &crate::storage::UnifiedEntity,
40) -> RedDBResult<()> {
41    if crate::runtime::rls_injection::entity_passes_rls_action(
42        runtime,
43        collection,
44        reddb_rql::ast::PolicyAction::Insert,
45        entity,
46    ) {
47        Ok(())
48    } else {
49        Err(crate::RedDBError::Query(format!(
50            "insert into collection '{collection}' denied by row-level security"
51        )))
52    }
53}
54
55/// Pull `(name, value)` pairs for every indexable field on an entity.
56/// Returns empty if field names are not recoverable here, so secondary-index
57/// maintenance has nothing to act on. Used by the delete + update paths.
58pub(crate) fn entity_row_fields_snapshot(
59    entity: &crate::storage::UnifiedEntity,
60) -> Vec<(String, Value)> {
61    let mut fields = match &entity.data {
62        crate::storage::EntityData::Row(row) => {
63            if let Some(named) = &row.named {
64                named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
65            } else if let Some(schema) = &row.schema {
66                schema
67                    .iter()
68                    .zip(row.columns.iter())
69                    .map(|(name, value)| (name.clone(), value.clone()))
70                    .collect()
71            } else {
72                Vec::new()
73            }
74        }
75        crate::storage::EntityData::Node(node) => node
76            .properties
77            .iter()
78            .map(|(k, v)| (k.clone(), v.clone()))
79            .collect(),
80        crate::storage::EntityData::Edge(edge) => edge
81            .properties
82            .iter()
83            .map(|(k, v)| (k.clone(), v.clone()))
84            .collect(),
85        _ => Vec::new(),
86    };
87    expand_binary_document_body_snapshot(&mut fields);
88    fields
89}
90
91fn expand_binary_document_body_snapshot(fields: &mut Vec<(String, Value)>) {
92    let Some(body_idx) = fields.iter().position(|(name, _)| name == "body") else {
93        return;
94    };
95    let Value::Json(bytes) = &fields[body_idx].1 else {
96        return;
97    };
98    let body_fields = crate::document_body::body_fields(bytes);
99    if let Some(body_json) = crate::document_body::decode_container_to_json(bytes) {
100        if let Ok(json_bytes) = crate::json::to_vec(&body_json) {
101            fields[body_idx].1 = Value::Json(json_bytes);
102        }
103    }
104    let Some(body_fields) = body_fields else {
105        return;
106    };
107    for (name, value) in body_fields {
108        if fields.iter().all(|(existing, _)| existing != &name) {
109            fields.push((name, value));
110        }
111    }
112}
113
114fn set_row_field(row: &mut crate::storage::unified::entity::RowData, name: &str, value: Value) {
115    if let Some(named) = row.named.as_mut() {
116        named.insert(name.to_string(), value);
117        return;
118    }
119
120    if let Some(schema) = row.schema.as_ref() {
121        if let Some(index) = schema.iter().position(|column| column == name) {
122            if let Some(slot) = row.columns.get_mut(index) {
123                *slot = value;
124                return;
125            }
126        }
127
128        let mut named = HashMap::with_capacity(schema.len().saturating_add(1));
129        for (column, current) in schema.iter().zip(row.columns.iter()) {
130            named.insert(column.clone(), current.clone());
131        }
132        named.insert(name.to_string(), value);
133        row.named = Some(named);
134        return;
135    }
136
137    let mut named = HashMap::with_capacity(1);
138    named.insert(name.to_string(), value);
139    row.named = Some(named);
140}
141
142fn collect_row_fields(row: &crate::storage::unified::entity::RowData) -> Vec<(String, Value)> {
143    if let Some(named) = row.named.as_ref() {
144        named
145            .iter()
146            .map(|(key, value)| (key.clone(), value.clone()))
147            .collect()
148    } else if let Some(schema) = row.schema.as_ref() {
149        schema
150            .iter()
151            .cloned()
152            .zip(row.columns.iter().cloned())
153            .collect()
154    } else {
155        Vec::new()
156    }
157}
158
159fn apply_row_field_assignments_raw<I>(
160    row: &mut crate::storage::unified::entity::RowData,
161    field_assignments: I,
162) where
163    I: IntoIterator<Item = (String, Value)>,
164{
165    for (column, value) in field_assignments {
166        set_row_field(row, &column, value);
167    }
168}
169
170fn apply_row_field_assignments_incremental<I>(
171    collection: &str,
172    row: &mut crate::storage::unified::entity::RowData,
173    field_assignments: I,
174    row_contract_plan: Option<&RowUpdateContractPlan>,
175) -> RedDBResult<()>
176where
177    I: IntoIterator<Item = (String, Value)>,
178{
179    for (column, value) in field_assignments {
180        let value = normalize_row_update_assignment_with_plan(
181            collection,
182            &column,
183            value,
184            row_contract_plan,
185        )?;
186
187        set_row_field(row, &column, value);
188    }
189
190    Ok(())
191}
192
193fn dedupe_modified_columns(mut modified_columns: Vec<String>) -> Vec<String> {
194    if modified_columns.is_empty() {
195        return modified_columns;
196    }
197
198    let mut unique = Vec::with_capacity(modified_columns.len());
199    for column in modified_columns.drain(..) {
200        if !unique
201            .iter()
202            .any(|existing: &String| existing.eq_ignore_ascii_case(&column))
203        {
204            unique.push(column);
205        }
206    }
207    unique
208}
209
210fn reject_document_array_position_path(path: &[String]) -> RedDBResult<()> {
211    if path.iter().any(|segment| segment.parse::<usize>().is_ok()) {
212        return Err(crate::RedDBError::Query(
213            "array positional document patch paths are unsupported; replace the array or full document body instead"
214                .to_string(),
215        ));
216    }
217    Ok(())
218}
219
220/// Reject a document body `set` whose path would traverse *through* an existing
221/// non-object value (e.g. `SET a.b = …` where `a` is already a scalar). This
222/// mirrors the HTTP JSON-patch contract: intermediate objects are created only
223/// where a slot is absent, never by silently clobbering a scalar. `body` is the
224/// pre-merge document body.
225fn reject_document_patch_path_through_scalar(body: &JsonValue, path: &[String]) -> RedDBResult<()> {
226    if path.len() < 2 {
227        return Ok(());
228    }
229    let scalar_error = |prefix: &str| {
230        crate::RedDBError::Query(format!(
231            "cannot set nested path '{}': '{}' is not an object; setting a path through a non-object value is unsupported — replace that value or the full document body instead",
232            path.join("."),
233            prefix
234        ))
235    };
236    let mut current = body;
237    // Walk the parent path (every segment but the leaf). Each container we index
238    // into must be an object; an absent slot is fine (the merge creates it).
239    for (idx, segment) in path[..path.len() - 1].iter().enumerate() {
240        let JsonValue::Object(map) = current else {
241            return Err(scalar_error(&path[..idx].join(".")));
242        };
243        match map.get(segment) {
244            Some(next) => current = next,
245            None => return Ok(()),
246        }
247    }
248    // `current` is now the leaf's parent container; it must be an object.
249    if !matches!(current, JsonValue::Object(_)) {
250        return Err(scalar_error(&path[..path.len() - 1].join(".")));
251    }
252    Ok(())
253}
254
255/// Apply document body `set`/`unset` operations, enforcing the shared document
256/// patch contract first: array-positional paths and paths that traverse through
257/// a scalar are rejected with clear errors before any mutation touches `body`.
258/// Used by both the SQL nested-`SET` executor and the HTTP JSON-patch operation
259/// path so the two surfaces stay consistent (ADR 0067).
260fn apply_document_body_patch_operations(
261    body: &mut JsonValue,
262    operations: &[PatchEntityOperation],
263) -> RedDBResult<()> {
264    for op in operations {
265        reject_document_array_position_path(&op.path)?;
266        if matches!(op.op, PatchEntityOperationType::Set) {
267            reject_document_patch_path_through_scalar(body, &op.path)?;
268        }
269    }
270    apply_patch_operations_to_json(body, operations).map_err(crate::RedDBError::Query)
271}
272
273fn document_body_from_row(
274    row: &crate::storage::unified::entity::RowData,
275) -> RedDBResult<JsonValue> {
276    match row.get_field("body") {
277        Some(Value::Json(bytes)) => {
278            // The stored body may be the native binary container (PRD-1398) or
279            // plain JSON; decode either form back to JSON.
280            if let Some(body) = crate::document_body::decode_container_to_json(bytes) {
281                Ok(body)
282            } else {
283                crate::json::from_slice(bytes).map_err(|err| {
284                    crate::RedDBError::Query(format!("failed to decode document body: {err}"))
285                })
286            }
287        }
288        Some(_) => Err(crate::RedDBError::Query(
289            "document body field must contain JSON".to_string(),
290        )),
291        None => Ok(JsonValue::Object(Default::default())),
292    }
293}
294
295fn document_body_from_assignment(value: &Value) -> RedDBResult<JsonValue> {
296    match value {
297        Value::Json(bytes) | Value::Blob(bytes) => crate::json::from_slice(bytes)
298            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
299        Value::Text(text) => crate::json::from_str(text.as_ref())
300            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
301        Value::Integer(value) => crate::json::from_str(&value.to_string())
302            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
303        Value::UnsignedInteger(value) => crate::json::from_str(&value.to_string())
304            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
305        Value::Float(value) => crate::json::from_str(&value.to_string())
306            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
307        other => Err(crate::RedDBError::Query(format!(
308            "column 'body' expected JSON body, got {other:?}"
309        ))),
310    }
311}
312
313fn document_body_set_operation(column: &str, value: Value) -> PatchEntityOperation {
314    PatchEntityOperation {
315        op: PatchEntityOperationType::Set,
316        path: column.split('.').map(str::to_string).collect(),
317        value: Some(value.to_json()),
318    }
319}
320
321fn replace_document_fields_body(
322    fields: &mut HashMap<String, Value>,
323    body: JsonValue,
324    binary: bool,
325    modified_columns: &mut Vec<String>,
326) -> RedDBResult<()> {
327    modified_columns.push("body".to_string());
328
329    let old_keys: Vec<String> = fields
330        .keys()
331        .filter(|key| key.as_str() != "body")
332        .cloned()
333        .collect();
334    for key in old_keys {
335        fields.remove(&key);
336        modified_columns.push(key);
337    }
338
339    let body_bytes = crate::document_body::serialize_document_body(&body, binary)?;
340    fields.insert("body".to_string(), Value::Json(body_bytes));
341
342    Ok(())
343}
344
345fn ensure_no_reserved_document_body_fields(body: &JsonValue, collection: &str) -> RedDBResult<()> {
346    if let JsonValue::Object(map) = body {
347        crate::reserved_fields::ensure_no_reserved_public_item_fields(
348            map.keys().map(String::as_str),
349            &format!("document '{collection}'"),
350        )?;
351    }
352    Ok(())
353}
354
355fn replace_document_row_body(
356    row: &mut crate::storage::unified::entity::RowData,
357    body: JsonValue,
358    collection: &str,
359    binary: bool,
360    modified_columns: &mut Vec<String>,
361) -> RedDBResult<()> {
362    ensure_no_reserved_document_body_fields(&body, collection)?;
363    let fields = row.named.get_or_insert_with(Default::default);
364    replace_document_fields_body(fields, body, binary, modified_columns)?;
365
366    // Full body replacement rebuilds the document from the canonical `body`.
367    // Stale positional/schema values would otherwise leak back through JSON
368    // presentation even after the named map was pruned.
369    row.columns.clear();
370    row.schema = None;
371    Ok(())
372}
373
374fn document_merge_patch_payload(payload: &JsonValue) -> &JsonValue {
375    if let JsonValue::Object(object) = payload {
376        if object.len() == 1 {
377            if let Some(body) = object.get("body") {
378                return body;
379            }
380        }
381    }
382    payload
383}
384
385fn apply_document_json_merge_patch(body: &mut JsonValue, patch: &JsonValue) -> RedDBResult<()> {
386    if !matches!(patch, JsonValue::Object(_)) {
387        *body = patch.clone();
388        return Ok(());
389    }
390
391    if matches!(patch, JsonValue::Object(object) if object.is_empty()) {
392        if !matches!(body, JsonValue::Object(_)) {
393            *body = JsonValue::Object(Default::default());
394        }
395        return Ok(());
396    }
397
398    let mut operations = Vec::new();
399    collect_document_json_merge_patch_operations(body, Vec::new(), patch, &mut operations);
400    apply_patch_operations_to_json(body, &operations).map_err(crate::RedDBError::Query)
401}
402
403fn collect_document_json_merge_patch_operations(
404    target: &JsonValue,
405    path: Vec<String>,
406    patch: &JsonValue,
407    operations: &mut Vec<PatchEntityOperation>,
408) {
409    let JsonValue::Object(patch_object) = patch else {
410        operations.push(PatchEntityOperation {
411            op: PatchEntityOperationType::Set,
412            path,
413            value: Some(patch.clone()),
414        });
415        return;
416    };
417
418    for (key, value) in patch_object {
419        let mut child_path = path.clone();
420        child_path.push(key.clone());
421        match value {
422            JsonValue::Null => operations.push(PatchEntityOperation {
423                op: PatchEntityOperationType::Unset,
424                path: child_path,
425                value: None,
426            }),
427            JsonValue::Object(object) if object.is_empty() => {
428                let child_target = match target {
429                    JsonValue::Object(target_object) => target_object.get(key),
430                    _ => None,
431                };
432                if !matches!(child_target, Some(JsonValue::Object(_))) {
433                    operations.push(PatchEntityOperation {
434                        op: PatchEntityOperationType::Set,
435                        path: child_path,
436                        value: Some(JsonValue::Object(Default::default())),
437                    });
438                }
439            }
440            JsonValue::Object(_) => {
441                let child_target = match target {
442                    JsonValue::Object(target_object) => target_object.get(key),
443                    _ => None,
444                }
445                .unwrap_or(&JsonValue::Null);
446                collect_document_json_merge_patch_operations(
447                    child_target,
448                    child_path,
449                    value,
450                    operations,
451                );
452            }
453            _ => operations.push(PatchEntityOperation {
454                op: PatchEntityOperationType::Set,
455                path: child_path,
456                value: Some(value.clone()),
457            }),
458        }
459    }
460}
461
462impl RedDBRuntime {
463    pub(crate) fn apply_loaded_patch_entity_core(
464        &self,
465        collection: String,
466        mut entity: crate::storage::UnifiedEntity,
467        payload: JsonValue,
468        operations: Vec<PatchEntityOperation>,
469    ) -> RedDBResult<AppliedEntityMutation> {
470        let id = entity.id;
471        let previous_xmax = entity.xmax;
472        let operations = normalize_ttl_patch_operations(operations)?;
473        // Snapshot pre-patch row fields for the secondary-index hook —
474        // empty for non-row entities, which is the desired no-op.
475        let pre_mutation_fields = entity_row_fields_snapshot(&entity);
476
477        // Versioned collections retain MVCC history: a PATCH/UPDATE must
478        // produce a NEW full version (the merged document/row/node) with
479        // the prior version tombstoned, mirroring the SQL UPDATE row
480        // path. Gated STRICTLY on the collection `versioned` flag —
481        // non-versioned collections keep last-writer-wins in-place
482        // mutation. Row entities (documents / table rows / KV) carry
483        // logical-id MVCC history (Phase 1/2); graph nodes & edges join
484        // in Phase 3. The post-image re-stamp (new physical id, same
485        // logical_id, fresh xmin) at the tail of this function is fully
486        // kind-agnostic, so the same machinery installs a versioned
487        // graph-node update.
488        let patch_versions = matches!(
489            entity.data,
490            crate::storage::EntityData::Row(_)
491                | crate::storage::EntityData::Node(_)
492                | crate::storage::EntityData::Edge(_)
493        ) && self.vcs_is_versioned(&collection).unwrap_or(false);
494        let versioned_update_xid = if patch_versions {
495            match self.current_xid() {
496                Some(xid) => Some(xid),
497                None => {
498                    let snapshot_manager = self.snapshot_manager();
499                    let xid = snapshot_manager.begin();
500                    snapshot_manager.commit(xid);
501                    Some(xid)
502                }
503            }
504        } else {
505            None
506        };
507        let mut replaced_entity = versioned_update_xid.map(|xid| {
508            let mut old = entity.clone();
509            old.set_xmax(xid);
510            old
511        });
512
513        let db = self.db();
514        let store = db.store();
515        if store.get_collection(&collection).is_none() {
516            return Err(crate::RedDBError::NotFound(format!(
517                "collection not found: {collection}"
518            )));
519        }
520
521        let mut patch_metadata: Option<crate::storage::unified::Metadata> = None;
522        let mut metadata_changed = false;
523        let mut modified_columns: Vec<String> = Vec::new();
524        let mut context_index_dirty = false;
525        let mut graph_node_type: Option<String> = None;
526        let mut graph_edge_weight: Option<f32> = None;
527
528        let row_contract_timestamps = db
529            .collection_contract(&collection)
530            .map(|c| c.timestamps_enabled)
531            .unwrap_or(false);
532
533        match &mut entity.data {
534            crate::storage::EntityData::Row(row) => {
535                // PATCH mutates a named map. A recovered row may instead own
536                // its values under a shared schema; move those values into
537                // the map before applying only the requested field changes.
538                if row.named.is_none() {
539                    if let Some(schema) = row.schema.as_ref() {
540                        let fields = if schema.len() == row.columns.len() {
541                            schema
542                                .iter()
543                                .cloned()
544                                .zip(std::mem::take(&mut row.columns))
545                                .collect()
546                        } else {
547                            collect_row_fields(row).into_iter().collect()
548                        };
549                        row.named = Some(fields);
550                    }
551                }
552                let is_document_collection = db
553                    .collection_contract(&collection)
554                    .map(|contract| {
555                        contract.declared_model == crate::catalog::CollectionModel::Document
556                    })
557                    .unwrap_or(false);
558                let binary_body = self.binary_document_body_enabled();
559                let mut field_ops = Vec::new();
560                let mut metadata_ops = Vec::new();
561                let mut document_body_ops = Vec::new();
562                let mut document_body_replace: Option<JsonValue> = None;
563                let has_patch_operations = !operations.is_empty();
564
565                for mut op in operations {
566                    let Some(root) = op.path.first().map(String::as_str) else {
567                        return Err(crate::RedDBError::Query(
568                            "patch path cannot be empty".to_string(),
569                        ));
570                    };
571
572                    match root {
573                        "body" if is_document_collection => {
574                            if op.path.len() == 1 {
575                                match op.op {
576                                    PatchEntityOperationType::Set
577                                    | PatchEntityOperationType::Replace => {
578                                        let Some(value) = op.value.take() else {
579                                            return Err(crate::RedDBError::Query(
580                                                "document body replacement requires a value"
581                                                    .to_string(),
582                                            ));
583                                        };
584                                        document_body_replace = Some(value);
585                                    }
586                                    PatchEntityOperationType::Unset => {
587                                        return Err(crate::RedDBError::Query(
588                                            "document body cannot be unset; replace it instead"
589                                                .to_string(),
590                                        ));
591                                    }
592                                }
593                                continue;
594                            }
595                            if op.path.len() < 2 {
596                                return Err(crate::RedDBError::Query(
597                                    "document body patch paths require a nested key; use payload.body for full replacement"
598                                        .to_string(),
599                                ));
600                            }
601                            op.path.remove(0);
602                            reject_document_array_position_path(&op.path)?;
603                            document_body_ops.push(op);
604                        }
605                        "fields" | "named" => {
606                            if op.path.len() < 2 {
607                                return Err(crate::RedDBError::Query(
608                                    "patch path 'fields' requires a nested key".to_string(),
609                                ));
610                            }
611                            if row_contract_timestamps {
612                                let leaf = op.path.get(1).map(String::as_str);
613                                if matches!(leaf, Some("created_at") | Some("updated_at")) {
614                                    return Err(crate::RedDBError::Query(format!(
615                                        "collection '{}' manages '{}' automatically — do not set it in UPDATE",
616                                        collection,
617                                        leaf.unwrap_or("")
618                                    )));
619                                }
620                            }
621                            op.path.remove(0);
622                            field_ops.push(op);
623                        }
624                        "metadata" => {
625                            if op.path.len() < 2 {
626                                return Err(crate::RedDBError::Query(
627                                    "patch path 'metadata' requires a nested key".to_string(),
628                                ));
629                            }
630                            op.path.remove(0);
631                            metadata_ops.push(op);
632                        }
633                        _ => {
634                            return Err(crate::RedDBError::Query(format!(
635                                "unsupported patch target '{root}' for table rows. Use fields/*, metadata/*, or weight"
636                            )));
637                        }
638                    }
639                }
640
641                if document_body_replace.is_some() || !document_body_ops.is_empty() {
642                    context_index_dirty = true;
643                    let mut body = match document_body_replace {
644                        Some(body) => body,
645                        None => document_body_from_row(row)?,
646                    };
647                    apply_document_body_patch_operations(&mut body, &document_body_ops)?;
648                    replace_document_row_body(
649                        row,
650                        body,
651                        &collection,
652                        binary_body,
653                        &mut modified_columns,
654                    )?;
655                }
656
657                if is_document_collection && !has_patch_operations {
658                    let mut body = document_body_from_row(row)?;
659                    let previous_body = body.clone();
660                    apply_document_json_merge_patch(
661                        &mut body,
662                        document_merge_patch_payload(&payload),
663                    )?;
664                    if body != previous_body {
665                        context_index_dirty = true;
666                        replace_document_row_body(
667                            row,
668                            body,
669                            &collection,
670                            binary_body,
671                            &mut modified_columns,
672                        )?;
673                    }
674                }
675
676                if !field_ops.is_empty() {
677                    context_index_dirty = true;
678                    if is_document_collection {
679                        // Document fields are projections of the canonical
680                        // `body` JSON. Apply the assignment to the body so the
681                        // stored document remains single-source.
682                        let mut body = document_body_from_row(row)?;
683                        apply_patch_operations_to_json(&mut body, &field_ops)
684                            .map_err(crate::RedDBError::Query)?;
685                        replace_document_row_body(
686                            row,
687                            body,
688                            &collection,
689                            binary_body,
690                            &mut modified_columns,
691                        )?;
692                    } else {
693                        let named = row.named.get_or_insert_with(Default::default);
694                        for op in &field_ops {
695                            if let Some(col) = op.path.first() {
696                                modified_columns.push(col.clone());
697                            }
698                        }
699                        apply_patch_operations_to_storage_map(named, &field_ops)?;
700                    }
701                }
702
703                if let Some(fields) = payload
704                    .get("fields")
705                    .and_then(crate::json::Value::as_object)
706                {
707                    if row_contract_timestamps {
708                        for key in fields.keys() {
709                            if key == "created_at" || key == "updated_at" {
710                                return Err(crate::RedDBError::Query(format!(
711                                    "collection '{}' manages '{}' automatically — do not set it in UPDATE",
712                                    collection, key
713                                )));
714                            }
715                        }
716                    }
717                    context_index_dirty = true;
718                    if is_document_collection {
719                        // Mirror the `field_ops` path above: keep the canonical
720                        // `body` JSON as the single stored representation.
721                        let mut body = document_body_from_row(row)?;
722                        if let JsonValue::Object(map) = &mut body {
723                            for (key, value) in fields {
724                                map.insert(key.clone(), value.clone());
725                            }
726                        }
727                        replace_document_row_body(
728                            row,
729                            body,
730                            &collection,
731                            binary_body,
732                            &mut modified_columns,
733                        )?;
734                    } else {
735                        let named = row.named.get_or_insert_with(Default::default);
736                        for (key, value) in fields {
737                            modified_columns.push(key.clone());
738                            named.insert(key.clone(), json_to_storage_value(value)?);
739                        }
740                    }
741                }
742
743                if !metadata_ops.is_empty() {
744                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
745                    let metadata = patch_metadata.get_or_insert_with(|| {
746                        store.get_metadata(&collection, id).unwrap_or_default()
747                    });
748                    let mut metadata_json = metadata_to_json(metadata);
749                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
750                        .map_err(crate::RedDBError::Query)?;
751                    *metadata = metadata_from_json(&metadata_json)?;
752                    metadata_changed = true;
753                }
754
755                if !modified_columns.is_empty() || row_contract_timestamps {
756                    let contract = CollectionContractWriteEnforcer::new(self, &db, &collection);
757                    let current_fields = if let Some(named) = row.named.take() {
758                        named.into_iter().collect::<Vec<_>>()
759                    } else if let Some(schema) = row.schema.as_ref() {
760                        schema
761                            .iter()
762                            .cloned()
763                            .zip(row.columns.iter().cloned())
764                            .collect::<Vec<_>>()
765                    } else {
766                        Vec::new()
767                    };
768                    let normalized_fields = contract.normalize_update_fields(current_fields)?;
769                    if let Some(schema) = db.collection_contract_arc(&collection) {
770                        for column in schema
771                            .declared_columns
772                            .iter()
773                            .filter(|column| column.generated.is_some())
774                        {
775                            modified_columns.push(column.name.clone());
776                            context_index_dirty = true;
777                        }
778                    }
779                    if row_contract_timestamps {
780                        modified_columns.push("updated_at".to_string());
781                        context_index_dirty = true;
782                    }
783                    if contract.requires_uniqueness_check(&modified_columns) {
784                        contract.enforce_row_uniqueness(&normalized_fields, Some(id))?;
785                    }
786                    if self
787                        .index_store_ref()
788                        .touches_unique_index(&collection, &modified_columns)
789                    {
790                        self.enforce_unique_updates(
791                            &collection,
792                            &[(id, normalized_fields.clone())],
793                        )?;
794                    }
795                    row.named = Some(normalized_fields.into_iter().collect());
796                }
797            }
798            crate::storage::EntityData::Node(node) => {
799                let mut field_ops = Vec::new();
800                let mut metadata_ops = Vec::new();
801                let mut node_type_ops = Vec::new();
802
803                for mut op in operations {
804                    let Some(root) = op.path.first().map(String::as_str) else {
805                        return Err(crate::RedDBError::Query(
806                            "patch path cannot be empty".to_string(),
807                        ));
808                    };
809
810                    match root {
811                        "fields" | "properties" => {
812                            if op.path.len() < 2 {
813                                return Err(crate::RedDBError::Query(
814                                    "patch path 'fields' requires a nested key".to_string(),
815                                ));
816                            }
817                            op.path.remove(0);
818                            field_ops.push(op);
819                        }
820                        "metadata" => {
821                            if op.path.len() < 2 {
822                                return Err(crate::RedDBError::Query(
823                                    "patch path 'metadata' requires a nested key".to_string(),
824                                ));
825                            }
826                            op.path.remove(0);
827                            metadata_ops.push(op);
828                        }
829                        "node_type" => {
830                            if op.path.len() != 1 {
831                                return Err(crate::RedDBError::Query(
832                                    "patch path 'node_type' does not allow nested keys".to_string(),
833                                ));
834                            }
835                            op.path.clear();
836                            node_type_ops.push(op);
837                        }
838                        _ => {
839                            return Err(crate::RedDBError::Query(format!(
840                                "unsupported patch target '{root}' for graph nodes. Use fields/*, properties/*, node_type, or metadata/*"
841                            )));
842                        }
843                    }
844                }
845
846                for op in node_type_ops {
847                    context_index_dirty = true;
848                    let value = op.value.ok_or_else(|| {
849                        crate::RedDBError::Query("node_type operations require a value".to_string())
850                    })?;
851
852                    match op.op {
853                        PatchEntityOperationType::Unset => {
854                            return Err(crate::RedDBError::Query(
855                                "node_type cannot be unset through patch operations".to_string(),
856                            ));
857                        }
858                        PatchEntityOperationType::Set | PatchEntityOperationType::Replace => {
859                            let Some(node_type) = value.as_str() else {
860                                return Err(crate::RedDBError::Query(
861                                    "node_type operation requires a text value".to_string(),
862                                ));
863                            };
864                            graph_node_type = Some(node_type.to_string());
865                            modified_columns.push("node_type".to_string());
866                        }
867                    }
868                }
869
870                if !field_ops.is_empty() {
871                    context_index_dirty = true;
872                    apply_patch_operations_to_storage_map(&mut node.properties, &field_ops)?;
873                }
874
875                if let Some(fields) = payload
876                    .get("fields")
877                    .and_then(crate::json::Value::as_object)
878                {
879                    context_index_dirty = true;
880                    for (key, value) in fields {
881                        node.properties
882                            .insert(key.clone(), json_to_storage_value(value)?);
883                    }
884                }
885
886                if !metadata_ops.is_empty() {
887                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
888                    let metadata = patch_metadata.get_or_insert_with(|| {
889                        store.get_metadata(&collection, id).unwrap_or_default()
890                    });
891                    let mut metadata_json = metadata_to_json(metadata);
892                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
893                        .map_err(crate::RedDBError::Query)?;
894                    *metadata = metadata_from_json(&metadata_json)?;
895                    metadata_changed = true;
896                }
897            }
898            crate::storage::EntityData::Edge(edge) => {
899                let mut field_ops = Vec::new();
900                let mut metadata_ops = Vec::new();
901                let mut weight_ops = Vec::new();
902
903                for mut op in operations {
904                    let Some(root) = op.path.first().map(String::as_str) else {
905                        return Err(crate::RedDBError::Query(
906                            "patch path cannot be empty".to_string(),
907                        ));
908                    };
909
910                    match root {
911                        "fields" | "properties" => {
912                            if op.path.len() < 2 {
913                                return Err(crate::RedDBError::Query(
914                                    "patch path 'fields' requires a nested key".to_string(),
915                                ));
916                            }
917                            op.path.remove(0);
918                            field_ops.push(op);
919                        }
920                        "weight" => {
921                            if op.path.len() != 1 {
922                                return Err(crate::RedDBError::Query(
923                                    "patch path 'weight' does not allow nested keys".to_string(),
924                                ));
925                            }
926                            op.path.clear();
927                            weight_ops.push(op);
928                        }
929                        "metadata" => {
930                            if op.path.len() < 2 {
931                                return Err(crate::RedDBError::Query(
932                                    "patch path 'metadata' requires a nested key".to_string(),
933                                ));
934                            }
935                            op.path.remove(0);
936                            metadata_ops.push(op);
937                        }
938                        _ => {
939                            return Err(crate::RedDBError::Query(format!(
940                                "unsupported patch target '{root}' for graph edges. Use fields/*, weight, metadata/*"
941                            )));
942                        }
943                    }
944                }
945
946                if !field_ops.is_empty() {
947                    context_index_dirty = true;
948                    apply_patch_operations_to_storage_map(&mut edge.properties, &field_ops)?;
949                }
950
951                for op in weight_ops {
952                    context_index_dirty = true;
953                    let value = op.value.ok_or_else(|| {
954                        crate::RedDBError::Query("weight operations require a value".to_string())
955                    })?;
956
957                    match op.op {
958                        PatchEntityOperationType::Unset => {
959                            return Err(crate::RedDBError::Query(
960                                "weight cannot be unset through patch operations".to_string(),
961                            ));
962                        }
963                        PatchEntityOperationType::Set | PatchEntityOperationType::Replace => {
964                            let Some(weight) = value.as_f64() else {
965                                return Err(crate::RedDBError::Query(
966                                    "weight operation requires a numeric value".to_string(),
967                                ));
968                            };
969                            graph_edge_weight = Some(weight as f32);
970                            edge.weight = weight as f32;
971                            modified_columns.push("weight".to_string());
972                        }
973                    }
974                }
975
976                if let Some(fields) = payload
977                    .get("fields")
978                    .and_then(crate::json::Value::as_object)
979                {
980                    context_index_dirty = true;
981                    for (key, value) in fields {
982                        edge.properties
983                            .insert(key.clone(), json_to_storage_value(value)?);
984                    }
985                }
986
987                if !metadata_ops.is_empty() {
988                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
989                    let metadata = patch_metadata.get_or_insert_with(|| {
990                        store.get_metadata(&collection, id).unwrap_or_default()
991                    });
992                    let mut metadata_json = metadata_to_json(metadata);
993                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
994                        .map_err(crate::RedDBError::Query)?;
995                    *metadata = metadata_from_json(&metadata_json)?;
996                    metadata_changed = true;
997                }
998            }
999            crate::storage::EntityData::Vector(vector) => {
1000                let mut field_ops = Vec::new();
1001                let mut metadata_ops = Vec::new();
1002
1003                for mut op in operations {
1004                    let Some(root) = op.path.first().map(String::as_str) else {
1005                        return Err(crate::RedDBError::Query(
1006                            "patch path cannot be empty".to_string(),
1007                        ));
1008                    };
1009
1010                    match root {
1011                        "fields" => {
1012                            if op.path.len() < 2 {
1013                                return Err(crate::RedDBError::Query(
1014                                    "patch path 'fields' requires a nested key".to_string(),
1015                                ));
1016                            }
1017                            op.path.remove(0);
1018                            let Some(target) = op.path.first().map(String::as_str) else {
1019                                return Err(crate::RedDBError::Query(
1020                                    "patch path requires a target under fields".to_string(),
1021                                ));
1022                            };
1023                            if !matches!(target, "dense" | "content" | "sparse") {
1024                                return Err(crate::RedDBError::Query(format!(
1025                                    "unsupported vector patch target '{target}'"
1026                                )));
1027                            }
1028                            field_ops.push(op);
1029                        }
1030                        "metadata" => {
1031                            if op.path.len() < 2 {
1032                                return Err(crate::RedDBError::Query(
1033                                    "patch path 'metadata' requires a nested key".to_string(),
1034                                ));
1035                            }
1036                            op.path.remove(0);
1037                            metadata_ops.push(op);
1038                        }
1039                        _ => {
1040                            return Err(crate::RedDBError::Query(format!(
1041                                "unsupported patch target '{root}' for vectors. Use fields/* or metadata/*"
1042                            )));
1043                        }
1044                    }
1045                }
1046
1047                if !field_ops.is_empty() {
1048                    context_index_dirty = true;
1049                    apply_patch_operations_to_vector_fields(vector, &field_ops)?;
1050                }
1051
1052                if let Some(fields) = payload
1053                    .get("fields")
1054                    .and_then(crate::json::Value::as_object)
1055                {
1056                    context_index_dirty = true;
1057                    if let Some(content) =
1058                        fields.get("content").and_then(crate::json::Value::as_str)
1059                    {
1060                        vector.content = Some(content.to_string());
1061                    }
1062                    if let Some(dense) = fields.get("dense") {
1063                        vector.dense = dense
1064                            .as_array()
1065                            .ok_or_else(|| {
1066                                crate::RedDBError::Query(
1067                                    "field 'dense' must be an array".to_string(),
1068                                )
1069                            })?
1070                            .iter()
1071                            .map(|value| {
1072                                value.as_f64().map(|value| value as f32).ok_or_else(|| {
1073                                    crate::RedDBError::Query(
1074                                        "field 'dense' must contain only numbers".to_string(),
1075                                    )
1076                                })
1077                            })
1078                            .collect::<Result<Vec<_>, _>>()?;
1079                    }
1080                }
1081
1082                if !metadata_ops.is_empty() {
1083                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
1084                    let metadata = patch_metadata.get_or_insert_with(|| {
1085                        store.get_metadata(&collection, id).unwrap_or_default()
1086                    });
1087                    let mut metadata_json = metadata_to_json(metadata);
1088                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
1089                        .map_err(crate::RedDBError::Query)?;
1090                    *metadata = metadata_from_json(&metadata_json)?;
1091                    metadata_changed = true;
1092                }
1093            }
1094            crate::storage::EntityData::TimeSeries(_)
1095            | crate::storage::EntityData::QueueMessage(_) => {
1096                return Err(crate::RedDBError::Query(
1097                    "patch operations are not supported for TimeSeries or QueueMessage entities"
1098                        .to_string(),
1099                ));
1100            }
1101        }
1102
1103        if let Some(node_type) = graph_node_type {
1104            if let crate::storage::EntityKind::GraphNode(node) = &mut entity.kind {
1105                node.node_type = node_type;
1106            }
1107        }
1108        if let Some(weight) = graph_edge_weight {
1109            if let crate::storage::EntityKind::GraphEdge(edge) = &mut entity.kind {
1110                edge.weight = (weight * 1000.0) as u32;
1111            }
1112        }
1113
1114        if let Some(metadata) = payload
1115            .get("metadata")
1116            .and_then(crate::json::Value::as_object)
1117        {
1118            let patch_metadata = patch_metadata
1119                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default());
1120            for (key, value) in metadata {
1121                ensure_non_tree_reserved_metadata_key(key)?;
1122                patch_metadata.set(key.clone(), json_to_metadata_value(value)?);
1123            }
1124            metadata_changed = true;
1125        }
1126
1127        for (key, value) in parse_top_level_ttl_metadata_entries(&payload)? {
1128            let patch_metadata = patch_metadata
1129                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default());
1130            if matches!(value, crate::storage::unified::MetadataValue::Null) {
1131                patch_metadata.remove(&key);
1132            } else {
1133                patch_metadata.set(key, value);
1134            }
1135            metadata_changed = true;
1136        }
1137
1138        entity.field_bloom =
1139            crate::storage::unified::entity::compute_entity_field_bloom(&entity.data);
1140        entity.updated_at = std::time::SystemTime::now()
1141            .duration_since(std::time::UNIX_EPOCH)
1142            .unwrap_or_default()
1143            .as_secs();
1144
1145        // Versioned PATCH: re-stamp the merged post-image as a fresh
1146        // physical version keyed on the same logical_id, and close the
1147        // prior version's xmax at the same xid. `install_versioned_*`
1148        // in persist installs the new row and tombstones the old one;
1149        // `record_pending_versioned_update` arms first-committer-wins.
1150        if let Some(xid) = versioned_update_xid {
1151            let logical_id = entity.logical_id();
1152            entity.id = store.next_entity_id();
1153            entity.set_logical_id(logical_id);
1154            entity.set_xmin(xid);
1155            entity.set_xmax(0);
1156            if let Some(old) = replaced_entity.as_mut() {
1157                old.set_xmax(xid);
1158            }
1159        }
1160
1161        modified_columns = dedupe_modified_columns(modified_columns);
1162
1163        Ok(AppliedEntityMutation {
1164            id: entity.id,
1165            collection,
1166            entity,
1167            metadata: patch_metadata,
1168            modified_columns,
1169            persist_metadata: metadata_changed,
1170            context_index_dirty,
1171            replaced_entity,
1172            replaced_entity_previous_xmax: previous_xmax,
1173            pre_mutation_fields,
1174        })
1175    }
1176
1177    pub(crate) fn apply_loaded_sql_update_row_core(
1178        &self,
1179        collection: String,
1180        mut entity: crate::storage::UnifiedEntity,
1181        static_field_assignments: &[(String, Value)],
1182        dynamic_field_assignments: Vec<(String, Value)>,
1183        static_metadata_assignments: &[(String, MetadataValue)],
1184        dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
1185        row_contract_plan: Option<&RowUpdateContractPlan>,
1186        row_modified_columns_template: &[String],
1187        row_touches_unique_columns: bool,
1188    ) -> RedDBResult<AppliedEntityMutation> {
1189        let id = entity.id;
1190        let previous_xmax = entity.xmax;
1191        let db = self.db();
1192        let store = db.store();
1193        let Some(_) = store.get_collection(&collection) else {
1194            return Err(crate::RedDBError::NotFound(format!(
1195                "collection not found: {collection}"
1196            )));
1197        };
1198
1199        let versioned_update_xid = match self.current_xid() {
1200            Some(xid) => Some(xid),
1201            None => {
1202                let snapshot_manager = self.snapshot_manager();
1203                let xid = snapshot_manager.begin();
1204                snapshot_manager.commit(xid);
1205                Some(xid)
1206            }
1207        };
1208        let mut replaced_entity = versioned_update_xid.map(|xid| {
1209            let mut old = entity.clone();
1210            old.set_xmax(xid);
1211            old
1212        });
1213
1214        let mut patch_metadata: Option<crate::storage::unified::Metadata> = None;
1215        let row_contract_timestamps = row_contract_plan
1216            .map(|plan| plan.timestamps_enabled)
1217            .unwrap_or(false);
1218        let mut metadata_changed = false;
1219        let mut modified_columns = row_modified_columns_template.to_vec();
1220        let mut context_index_dirty = !modified_columns.is_empty();
1221
1222        // Snapshot OLD field values BEFORE applying the assignments —
1223        // the secondary-index maintenance hook needs both before/after to
1224        // delete-then-insert under changed indexed columns.
1225        let pre_mutation_fields = entity_row_fields_snapshot(&entity);
1226
1227        let crate::storage::EntityData::Row(row) = &mut entity.data else {
1228            return Err(crate::RedDBError::Query(
1229                "SQL row update fast path requires a row entity".to_string(),
1230            ));
1231        };
1232
1233        let _ = row_contract_plan;
1234
1235        // Documents store the full JSON under the `body` column. Legacy rows
1236        // also promote top-level keys for filtering, while binary body mode
1237        // keeps `body` as the single source of truth. Reflect non-body SET
1238        // assignments into `body` as patch operations so dotted paths update
1239        // nested JSON instead of becoming literal top-level keys.
1240        let is_document_collection = db
1241            .collection_contract(&collection)
1242            .map(|contract| contract.declared_model == crate::catalog::CollectionModel::Document)
1243            .unwrap_or(false);
1244        let kv_key = if row.get_field("value").is_some() || row.columns.len() >= 2 {
1245            row.get_field("key")
1246                .cloned()
1247                .or_else(|| row.columns.first().cloned())
1248        } else {
1249            None
1250        };
1251        if is_document_collection {
1252            // #1366: when a SET assigns to `body`, treat it as a FULL REPLACE of
1253            // the stored document — drop other column assignments, the new body
1254            // is the single source of truth.
1255            let body_assignment = static_field_assignments
1256                .iter()
1257                .chain(dynamic_field_assignments.iter())
1258                .rev()
1259                .find(|(column, _)| column == "body")
1260                .cloned();
1261
1262            match body_assignment {
1263                Some((_, value)) => {
1264                    let body = document_body_from_assignment(&value)?;
1265                    replace_document_row_body(
1266                        row,
1267                        body,
1268                        &collection,
1269                        self.binary_document_body_enabled(),
1270                        &mut modified_columns,
1271                    )?;
1272                    context_index_dirty = true;
1273                }
1274                None => {
1275                    // #1365: no `body` assignment — fall back to nested-path
1276                    // partial update via document_body_set_operation.
1277                    let mut static_row_assignments = Vec::new();
1278                    let mut dynamic_row_assignments = Vec::new();
1279                    let mut document_body_ops = Vec::new();
1280                    for (column, value) in static_field_assignments.iter().cloned() {
1281                        if column == "body" {
1282                            static_row_assignments.push((column, value));
1283                        } else {
1284                            document_body_ops.push(document_body_set_operation(&column, value));
1285                        }
1286                    }
1287                    for (column, value) in dynamic_field_assignments {
1288                        if column == "body" {
1289                            dynamic_row_assignments.push((column, value));
1290                        } else {
1291                            document_body_ops.push(document_body_set_operation(&column, value));
1292                        }
1293                    }
1294                    apply_row_field_assignments_raw(row, static_row_assignments);
1295                    apply_row_field_assignments_raw(row, dynamic_row_assignments);
1296                    if !document_body_ops.is_empty() {
1297                        let mut body = document_body_from_row(row)?;
1298                        apply_document_body_patch_operations(&mut body, &document_body_ops)?;
1299                        replace_document_row_body(
1300                            row,
1301                            body,
1302                            &collection,
1303                            self.binary_document_body_enabled(),
1304                            &mut modified_columns,
1305                        )?;
1306                        context_index_dirty = true;
1307                    }
1308                }
1309            }
1310        } else {
1311            // Non-document collection: every assignment is a regular row column.
1312            let mut all_assignments: Vec<(String, Value)> = static_field_assignments.to_vec();
1313            all_assignments.extend(dynamic_field_assignments);
1314            apply_row_field_assignments_raw(row, all_assignments);
1315            if let Some(key) = kv_key {
1316                set_row_field(row, "key", key);
1317            }
1318        }
1319
1320        for (key, value) in static_metadata_assignments
1321            .iter()
1322            .cloned()
1323            .chain(dynamic_metadata_assignments)
1324        {
1325            ensure_non_tree_reserved_metadata_key(&key)?;
1326            patch_metadata
1327                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default())
1328                .set(key, value);
1329            metadata_changed = true;
1330        }
1331
1332        if !modified_columns.is_empty() || row_contract_timestamps {
1333            let contract = CollectionContractWriteEnforcer::new(self, &db, &collection);
1334            if row_contract_timestamps {
1335                context_index_dirty = true;
1336                set_row_field(row, "updated_at", contract.managed_timestamp_value());
1337                modified_columns.push("updated_at".to_string());
1338            }
1339            let expression_contract = if row_contract_plan.is_some_and(|plan| plan.has_expressions)
1340            {
1341                db.collection_contract_arc(&collection)
1342            } else {
1343                None
1344            };
1345            if let Some(schema) = &expression_contract {
1346                let normalized = contract.normalize_update_fields(collect_row_fields(row))?;
1347                for column in schema
1348                    .declared_columns
1349                    .iter()
1350                    .filter(|column| column.generated.is_some())
1351                {
1352                    modified_columns.push(column.name.clone());
1353                }
1354                for (name, value) in normalized {
1355                    set_row_field(row, &name, value);
1356                }
1357                context_index_dirty = true;
1358            }
1359            if row_touches_unique_columns || expression_contract.is_some() {
1360                let current_fields = collect_row_fields(row);
1361                contract.enforce_row_uniqueness(&current_fields, Some(id))?;
1362            }
1363        }
1364
1365        entity.field_bloom =
1366            crate::storage::unified::entity::compute_entity_field_bloom(&entity.data);
1367        entity.updated_at = std::time::SystemTime::now()
1368            .duration_since(std::time::UNIX_EPOCH)
1369            .unwrap_or_default()
1370            .as_secs();
1371
1372        if let Some(xid) = versioned_update_xid {
1373            let logical_id = entity.logical_id();
1374            entity.id = store.next_entity_id();
1375            entity.set_logical_id(logical_id);
1376            entity.set_xmin(xid);
1377            entity.set_xmax(0);
1378            if let Some(old) = replaced_entity.as_mut() {
1379                old.set_xmax(xid);
1380            }
1381        }
1382
1383        modified_columns = dedupe_modified_columns(modified_columns);
1384
1385        Ok(AppliedEntityMutation {
1386            id: entity.id,
1387            collection,
1388            entity,
1389            metadata: patch_metadata,
1390            modified_columns,
1391            persist_metadata: metadata_changed,
1392            context_index_dirty,
1393            replaced_entity,
1394            replaced_entity_previous_xmax: previous_xmax,
1395            pre_mutation_fields,
1396        })
1397    }
1398
1399    pub(crate) fn persist_applied_entity_mutations(
1400        &self,
1401        applied: &[AppliedEntityMutation],
1402    ) -> RedDBResult<()> {
1403        if applied.is_empty() {
1404            return Ok(());
1405        }
1406
1407        let store = self.db().store();
1408        // Fence the in-memory change and its WAL append together. Otherwise a
1409        // checkpoint can copy a new transaction's rows or truncate a concurrent
1410        // autocommit's WAL before those rows belong to its frozen generation.
1411        let _publication = store.vault_publication_lock.lock();
1412        let collection = &applied[0].collection;
1413        let Some(manager) = store.get_collection(collection) else {
1414            return Err(crate::RedDBError::NotFound(format!(
1415                "collection not found: {collection}"
1416            )));
1417        };
1418
1419        let mut ordinary = Vec::with_capacity(applied.len());
1420        for item in applied {
1421            if let Some(old_version) = item.replaced_entity.as_ref() {
1422                store
1423                    .install_versioned_table_row_update(
1424                        collection,
1425                        old_version.clone(),
1426                        item.entity.clone(),
1427                        if item.persist_metadata {
1428                            item.metadata.as_ref()
1429                        } else {
1430                            None
1431                        },
1432                    )
1433                    .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
1434                if self.current_xid().is_some() {
1435                    self.record_pending_versioned_update(
1436                        crate::runtime::impl_core::current_connection_id(),
1437                        collection,
1438                        old_version.id,
1439                        item.entity.id,
1440                        old_version.xmax,
1441                        item.replaced_entity_previous_xmax,
1442                    );
1443                }
1444            } else {
1445                ordinary.push(item);
1446            }
1447        }
1448        if ordinary.is_empty() {
1449            return Ok(());
1450        }
1451
1452        manager
1453            .update_hot_batch_with_metadata(ordinary.iter().map(|item| {
1454                (
1455                    &item.entity,
1456                    item.modified_columns.as_slice(),
1457                    if item.persist_metadata {
1458                        item.metadata.as_ref()
1459                    } else {
1460                        None
1461                    },
1462                )
1463            }))
1464            .map_err(|err| crate::RedDBError::Query(err.to_string()))?;
1465
1466        // PG-HOT-like fast path: segment in-place is done; when no
1467        // mutation touches a secondary-indexed column AND no metadata
1468        // payload needs to be folded into a B-tree record, skip the
1469        // in-line B-tree upsert. The WAL still records the update
1470        // (durability preserved; recovery replay rebuilds the B-tree),
1471        // and `manager.get()` prefers the live segment over the
1472        // B-tree for reads — so the short-circuit is invisible to
1473        // callers. See `persist_entities_to_pager_wal_only`.
1474        let indexed_cols = self
1475            .index_store_ref()
1476            .indexed_columns_set(collection.as_str());
1477        let all_hot = !indexed_cols.is_empty()
1478            && ordinary.iter().all(|item| {
1479                !item.persist_metadata
1480                    && !item
1481                        .modified_columns
1482                        .iter()
1483                        .any(|c| indexed_cols.contains(c))
1484            })
1485            || indexed_cols.is_empty() && ordinary.iter().all(|item| !item.persist_metadata);
1486
1487        // Pass `&[&UnifiedEntity]` — no per-entity clone. The SQL UPDATE
1488        // inner loop hands us `applied` which already owns the post-image
1489        // entity; all we need for the persist path is a read borrow.
1490        let entity_refs: Vec<&crate::storage::UnifiedEntity> =
1491            ordinary.iter().map(|item| &item.entity).collect();
1492        let persist_fn = if all_hot {
1493            crate::storage::unified::UnifiedStore::persist_entity_refs_to_pager_wal_only
1494        } else {
1495            crate::storage::unified::UnifiedStore::persist_entity_refs_to_pager
1496        };
1497        persist_fn(store.as_ref(), collection, &entity_refs)
1498            .map_err(|err| crate::RedDBError::Internal(err.to_string()))
1499    }
1500
1501    pub(crate) fn flush_applied_entity_mutation(
1502        &self,
1503        applied: &AppliedEntityMutation,
1504        topology_guard: parking_lot::RwLockReadGuard<'_, ()>,
1505    ) -> RedDBResult<()> {
1506        let store = self.db().store();
1507        if applied.context_index_dirty {
1508            store
1509                .context_index()
1510                .index_entity(&applied.collection, &applied.entity);
1511        }
1512        // Secondary-index maintenance for SQL UPDATE / JSON-Patch flows.
1513        // Skip when pre_mutation_fields is empty (entity wasn't a row, or
1514        // didn't carry recoverable column names) — there's nothing to
1515        // delete-then-insert in that case.
1516        //
1517        // Also build the CDC damage vector here so downstream consumers
1518        // see which columns changed without re-diffing.
1519        let mut changed_columns: Option<Vec<String>> = None;
1520        if !applied.pre_mutation_fields.is_empty() {
1521            let post = entity_row_fields_snapshot(&applied.entity);
1522            if !post.is_empty() {
1523                let damage = crate::application::entity::row_damage_vector(
1524                    &applied.pre_mutation_fields,
1525                    &post,
1526                );
1527                if !damage.is_empty() {
1528                    changed_columns = Some(
1529                        damage
1530                            .touched_columns()
1531                            .into_iter()
1532                            .map(str::to_string)
1533                            .collect(),
1534                    );
1535                }
1536
1537                // HOT-like fast path (P3.T2/T3): when no modified
1538                // column is covered by a secondary index, skip the
1539                // `index_entity_update` call entirely. The function
1540                // would short-circuit internally, but the call still
1541                // reads the registry lock + walks the damage vector
1542                // — avoiding it saves a few microseconds per UPDATE.
1543                // Page-local replace + t_ctid chain support (true
1544                // HOT) lives in a follow-up storage spec.
1545                // Use the parent-expanded set so that dot-path indexes
1546                // (e.g. "body.service.tier") are triggered when the root
1547                // field ("body") appears in modified_columns.
1548                let indexed_cols: std::collections::HashSet<String> = self
1549                    .index_store_ref()
1550                    .indexed_columns_set_with_parents(applied.collection.as_str());
1551                // Single-source documents keep promoted index columns in the
1552                // body — surface them so the index sees the value move (a no-op
1553                // for ordinary stored columns).
1554                let pre_index_fields = self
1555                    .index_store_ref()
1556                    .augment_body_derived_index_fields(&applied.pre_mutation_fields, &indexed_cols);
1557                let post_index_fields = self
1558                    .index_store_ref()
1559                    .augment_body_derived_index_fields(&post, &indexed_cols);
1560                let modified_cols: std::collections::HashSet<String> =
1561                    crate::application::entity::row_damage_vector(
1562                        &pre_index_fields,
1563                        &post_index_fields,
1564                    )
1565                    .touched_columns()
1566                    .into_iter()
1567                    .map(str::to_string)
1568                    .collect();
1569                if let Some(old_version) = applied.replaced_entity.as_ref() {
1570                    let old_index_fields: Vec<(String, Value)> = pre_index_fields
1571                        .iter()
1572                        .filter(|(col, _)| indexed_cols.contains(col))
1573                        .cloned()
1574                        .collect();
1575                    let new_index_fields: Vec<(String, Value)> = post_index_fields
1576                        .iter()
1577                        .filter(|(col, _)| indexed_cols.contains(col))
1578                        .cloned()
1579                        .collect();
1580                    if !old_index_fields.is_empty() {
1581                        self.index_store_ref()
1582                            .index_entity_delete(
1583                                &applied.collection,
1584                                old_version.id,
1585                                &old_index_fields,
1586                            )
1587                            .map_err(crate::RedDBError::Internal)?;
1588                    }
1589                    if !new_index_fields.is_empty() {
1590                        self.index_store_ref()
1591                            .index_entity_insert(
1592                                &applied.collection,
1593                                applied.entity.id,
1594                                &new_index_fields,
1595                            )
1596                            .map_err(crate::RedDBError::Internal)?;
1597                    }
1598                } else {
1599                    let decision = crate::storage::engine::hot_update::decide(
1600                        &crate::storage::engine::hot_update::HotUpdateInputs {
1601                            collection: applied.collection.as_str(),
1602                            indexed_columns: &indexed_cols,
1603                            modified_columns: &modified_cols,
1604                            // The storage layer currently handles fit via
1605                            // the segment abstraction; we bypass the
1606                            // page-size check here.
1607                            new_tuple_size: 0,
1608                            page_free_space: usize::MAX,
1609                        },
1610                    );
1611                    if !decision.can_hot {
1612                        self.index_store_ref()
1613                            .index_entity_update(
1614                                &applied.collection,
1615                                applied.id,
1616                                &pre_index_fields,
1617                                &post_index_fields,
1618                            )
1619                            .map_err(crate::RedDBError::Internal)?;
1620                    } else {
1621                        // F-04: `applied.collection` is tenant-supplied;
1622                        // strip CR/LF/control bytes via the LogField
1623                        // escaper (ADR 0010).
1624                        tracing::debug!(
1625                            collection = %reddb_wire::audit_safe_log_field(&applied.collection),
1626                            "hot_update fast-path: skipped index_entity_update"
1627                        );
1628                    }
1629                }
1630            }
1631        }
1632        drop(topology_guard);
1633        #[cfg(test)]
1634        self.index_store_ref().mutation_test_hook(
1635            &applied.collection,
1636            crate::runtime::MutationTestPhase::BeforeEvents,
1637        );
1638        self.cdc_emit_prebuilt_with_columns(
1639            crate::replication::cdc::ChangeOperation::Update,
1640            &applied.collection,
1641            &applied.entity,
1642            "entity",
1643            applied.metadata.as_ref(),
1644            true,
1645            changed_columns,
1646        );
1647        Ok(())
1648    }
1649
1650    pub(crate) fn apply_loaded_patch_entity(
1651        &self,
1652        collection: String,
1653        entity: crate::storage::UnifiedEntity,
1654        payload: JsonValue,
1655        operations: Vec<PatchEntityOperation>,
1656        topology_guard: parking_lot::RwLockReadGuard<'_, ()>,
1657    ) -> RedDBResult<CreateEntityOutput> {
1658        let applied =
1659            self.apply_loaded_patch_entity_core(collection, entity, payload, operations)?;
1660        if !crate::runtime::rls_injection::entity_passes_rls_action(
1661            self,
1662            &applied.collection,
1663            reddb_rql::ast::PolicyAction::Update,
1664            &applied.entity,
1665        ) {
1666            return Err(crate::RedDBError::NotFound("entity not found".to_string()));
1667        }
1668        let _reservation = self.admit_entity_mutation(&applied)?;
1669        self.persist_applied_entity_mutations(std::slice::from_ref(&applied))?;
1670        #[cfg(test)]
1671        self.index_store_ref().mutation_test_hook(
1672            &applied.collection,
1673            crate::runtime::MutationTestPhase::StoragePublished,
1674        );
1675        self.flush_applied_entity_mutation(&applied, topology_guard)?;
1676        Ok(CreateEntityOutput {
1677            id: applied.id,
1678            entity: Some(public_document_entity(applied.entity)),
1679        })
1680    }
1681}
1682
1683/// Shape the canonical binary-body document into the public response entity.
1684/// The stored `body` is the single source of truth (RDOC binary container),
1685/// but callers read the returned entity two ways, so the response satisfies
1686/// both: top-level body fields are surfaced into `named` (mirroring the GET
1687/// presentation derive in `named_fields_json`), and `body` itself is decoded
1688/// back to plain JSON. Derivation runs against the binary container first,
1689/// then `body` is replaced with its JSON form. This only reshapes the
1690/// returned copy — persistence already happened against the binary body.
1691fn public_document_entity(
1692    mut entity: crate::storage::UnifiedEntity,
1693) -> crate::storage::UnifiedEntity {
1694    let crate::storage::EntityData::Row(row) = &mut entity.data else {
1695        return entity;
1696    };
1697    let Some(named) = row.named.as_mut() else {
1698        return entity;
1699    };
1700    let Some(Value::Json(bytes)) = named.get("body") else {
1701        return entity;
1702    };
1703    let bytes = bytes.clone();
1704    if let Some(fields) = crate::document_body::body_fields(&bytes) {
1705        for (name, value) in fields {
1706            named.entry(name).or_insert(value);
1707        }
1708    }
1709    if let Some(body_json) = crate::document_body::decode_container_to_json(&bytes) {
1710        if let Ok(json_bytes) = crate::json::to_vec(&body_json) {
1711            named.insert("body".to_string(), Value::Json(json_bytes));
1712        }
1713    }
1714    entity
1715}
1716
1717fn ensure_non_tree_reserved_metadata_patch_paths(
1718    operations: &[PatchEntityOperation],
1719) -> RedDBResult<()> {
1720    for operation in operations {
1721        let Some(key) = operation.path.first().map(String::as_str) else {
1722            continue;
1723        };
1724        ensure_non_tree_reserved_metadata_key(key)?;
1725    }
1726    Ok(())
1727}
1728
1729fn ensure_non_tree_reserved_metadata_key(key: &str) -> RedDBResult<()> {
1730    if key.starts_with(TREE_METADATA_PREFIX) {
1731        return Err(crate::RedDBError::Query(format!(
1732            "metadata key '{}' is reserved for managed trees",
1733            key
1734        )));
1735    }
1736    Ok(())
1737}
1738
1739fn ensure_non_tree_reserved_metadata_entries(
1740    metadata: &[(String, MetadataValue)],
1741) -> RedDBResult<()> {
1742    for (key, _) in metadata {
1743        ensure_non_tree_reserved_metadata_key(key)?;
1744    }
1745    Ok(())
1746}
1747
1748fn ensure_non_tree_structural_edge_label(label: &str) -> RedDBResult<()> {
1749    if label.eq_ignore_ascii_case(TREE_CHILD_EDGE_LABEL) {
1750        return Err(crate::RedDBError::Query(format!(
1751            "edge label '{}' is reserved for managed trees",
1752            TREE_CHILD_EDGE_LABEL
1753        )));
1754    }
1755    Ok(())
1756}
1757
1758impl RedDBRuntime {
1759    pub(crate) fn create_node_unchecked(
1760        &self,
1761        mut input: CreateNodeInput,
1762    ) -> RedDBResult<CreateEntityOutput> {
1763        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Insert, &input.collection)?;
1764        crate::runtime::mutation::enforce_tenant_fields(
1765            self,
1766            &input.collection,
1767            &mut input.properties,
1768        )?;
1769        let node_type = input
1770            .node_type
1771            .clone()
1772            .unwrap_or_else(|| input.label.clone());
1773        let candidate = crate::storage::UnifiedEntity::new(
1774            crate::storage::EntityId::new(0),
1775            crate::storage::EntityKind::GraphNode(Box::new(
1776                crate::storage::unified::GraphNodeKind {
1777                    label: input.label.clone(),
1778                    node_type,
1779                },
1780            )),
1781            crate::storage::EntityData::Node(crate::storage::NodeData::with_properties(
1782                input.properties.iter().cloned().collect(),
1783            )),
1784        );
1785        ensure_insert_entity_rls(self, &input.collection, &candidate)?;
1786        let db = self.db();
1787        let contract = CollectionContractWriteEnforcer::new(self, &db, &input.collection);
1788        contract.ensure_model(crate::catalog::CollectionModel::Graph)?;
1789        let mut metadata = input.metadata;
1790        contract.apply_default_ttl(&mut metadata);
1791        let mut builder = db.node(&input.collection, &input.label);
1792
1793        if let Some(node_type) = input.node_type {
1794            builder = builder.node_type(node_type);
1795        }
1796
1797        for (key, value) in input.properties {
1798            builder = builder.property(key, value);
1799        }
1800
1801        for (key, value) in metadata {
1802            builder = builder.metadata(key, value);
1803        }
1804
1805        for embedding in input.embeddings {
1806            if let Some(model) = embedding.model {
1807                builder = builder.embedding_with_model(embedding.name, embedding.vector, model);
1808            } else {
1809                builder = builder.embedding(embedding.name, embedding.vector);
1810            }
1811        }
1812
1813        for link in input.table_links {
1814            builder = builder.link_to_table(link.key, link.table);
1815        }
1816
1817        for link in input.node_links {
1818            builder = builder.link_to_weighted(link.target, link.edge_label, link.weight);
1819        }
1820
1821        let id = builder.save()?;
1822        // Phase 1.1 MVCC universal: stamp xmin so concurrent snapshots
1823        // don't see this node until the transaction commits.
1824        self.stamp_xmin_if_in_txn(&input.collection, id);
1825        refresh_context_index(&db, &input.collection, id)?;
1826        self.cdc_emit(
1827            crate::replication::cdc::ChangeOperation::Insert,
1828            &input.collection,
1829            id.raw(),
1830            "graph_node",
1831        );
1832        Ok(CreateEntityOutput {
1833            id,
1834            entity: db.store().get(&input.collection, id),
1835        })
1836    }
1837
1838    pub(crate) fn create_edge_unchecked(
1839        &self,
1840        mut input: CreateEdgeInput,
1841    ) -> RedDBResult<CreateEntityOutput> {
1842        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Insert, &input.collection)?;
1843        crate::runtime::mutation::enforce_tenant_fields(
1844            self,
1845            &input.collection,
1846            &mut input.properties,
1847        )?;
1848        let weight = input.weight.unwrap_or(1.0);
1849        let candidate = crate::storage::UnifiedEntity::new(
1850            crate::storage::EntityId::new(0),
1851            crate::storage::EntityKind::GraphEdge(Box::new(
1852                crate::storage::unified::GraphEdgeKind {
1853                    label: input.label.clone(),
1854                    from_node: input.from.raw().to_string(),
1855                    to_node: input.to.raw().to_string(),
1856                    weight: (weight * 1000.0) as u32,
1857                },
1858            )),
1859            crate::storage::EntityData::Edge(crate::storage::EdgeData::with_properties(
1860                weight,
1861                input.properties.iter().cloned().collect(),
1862            )),
1863        );
1864        ensure_insert_entity_rls(self, &input.collection, &candidate)?;
1865        let db = self.db();
1866        let contract = CollectionContractWriteEnforcer::new(self, &db, &input.collection);
1867        contract.ensure_model(crate::catalog::CollectionModel::Graph)?;
1868        let mut metadata = input.metadata;
1869        contract.apply_default_ttl(&mut metadata);
1870        let mut builder = db
1871            .edge(&input.collection, &input.label)
1872            .from(input.from)
1873            .to(input.to);
1874
1875        if let Some(weight) = input.weight {
1876            builder = builder.weight(weight);
1877        }
1878
1879        for (key, value) in input.properties {
1880            builder = builder.property(key, value);
1881        }
1882
1883        for (key, value) in metadata {
1884            builder = builder.metadata(key, value);
1885        }
1886
1887        let id = builder.save()?;
1888        // Phase 1.1 MVCC universal: stamp xmin on the edge so other
1889        // sessions don't follow it until COMMIT.
1890        self.stamp_xmin_if_in_txn(&input.collection, id);
1891        refresh_context_index(&db, &input.collection, id)?;
1892        self.cdc_emit(
1893            crate::replication::cdc::ChangeOperation::Insert,
1894            &input.collection,
1895            id.raw(),
1896            "graph_edge",
1897        );
1898        Ok(CreateEntityOutput {
1899            id,
1900            entity: db.store().get(&input.collection, id),
1901        })
1902    }
1903}
1904
1905fn create_rows_batch_prevalidated_columnar_with_outputs(
1906    runtime: &RedDBRuntime,
1907    collection: String,
1908    column_names: std::sync::Arc<Vec<String>>,
1909    rows: Vec<Vec<reddb_types::Value>>,
1910) -> RedDBResult<Vec<CreateEntityOutput>> {
1911    use crate::storage::{
1912        unified::{EntityData, EntityKind, RowData},
1913        EntityId, UnifiedEntity,
1914    };
1915    use std::sync::Arc;
1916
1917    if rows.is_empty() {
1918        return Ok(Vec::new());
1919    }
1920    if crate::runtime::execution_context::current_auth_identity().is_some() {
1921        let inputs = rows
1922            .into_iter()
1923            .map(|values| CreateRowInput {
1924                collection: collection.clone(),
1925                fields: column_names.iter().cloned().zip(values).collect(),
1926                metadata: Vec::new(),
1927                node_links: Vec::new(),
1928                vector_links: Vec::new(),
1929            })
1930            .collect();
1931        return runtime.create_rows_batch_prevalidated_with_outputs(CreateRowsBatchInput {
1932            collection,
1933            rows: inputs,
1934            suppress_events: false,
1935        });
1936    }
1937    // Schema prevalidation does not admit standalone index keys. Route these
1938    // collections through the guarded row kernel before installing any row.
1939    if runtime.index_store_ref().has_unique_index(&collection) {
1940        return runtime.create_rows_batch_columnar_with_outputs(collection, column_names, rows);
1941    }
1942    runtime.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
1943    runtime.check_batch_size(rows.len())?;
1944    runtime.check_db_size()?;
1945
1946    let db = runtime.db();
1947    let contract = CollectionContractWriteEnforcer::new(runtime, &db, &collection);
1948    contract.ensure_model(crate::catalog::CollectionModel::Table)?;
1949
1950    let store = db.store();
1951    let table_arc: Arc<str> = Arc::from(collection.as_str());
1952
1953    let indexed_cols = runtime
1954        .index_store_ref()
1955        .indexed_columns_set(collection.as_str());
1956    let has_secondary_indexes = !indexed_cols.is_empty();
1957    let mut field_snapshots: Vec<Vec<(String, reddb_types::Value)>> = if has_secondary_indexes {
1958        Vec::with_capacity(rows.len())
1959    } else {
1960        Vec::new()
1961    };
1962
1963    let entities: Vec<UnifiedEntity> = rows
1964        .into_iter()
1965        .map(|values| {
1966            if has_secondary_indexes {
1967                let mut snap: Vec<(String, reddb_types::Value)> =
1968                    Vec::with_capacity(indexed_cols.len());
1969                for (name, val) in column_names.iter().zip(values.iter()) {
1970                    if indexed_cols.contains(name) {
1971                        snap.push((name.clone(), val.clone()));
1972                    }
1973                }
1974                field_snapshots.push(snap);
1975            }
1976            let mut row = RowData::new(values);
1977            row.schema = Some(Arc::clone(&column_names));
1978            UnifiedEntity::new(
1979                EntityId::new(0),
1980                EntityKind::TableRow {
1981                    table: Arc::clone(&table_arc),
1982                    row_id: 0,
1983                },
1984                EntityData::Row(row),
1985            )
1986        })
1987        .collect();
1988
1989    let ids = store
1990        .bulk_insert(&collection, entities)
1991        .map_err(|e| crate::RedDBError::Internal(format!("{e:?}")))?;
1992
1993    if has_secondary_indexes {
1994        let index_rows: Vec<(EntityId, Vec<(String, reddb_types::Value)>)> = ids
1995            .iter()
1996            .zip(field_snapshots)
1997            .map(|(id, fields)| (*id, fields))
1998            .collect();
1999        runtime
2000            .index_store_ref()
2001            .index_entity_insert_batch(&collection, &index_rows)
2002            .map_err(crate::RedDBError::Internal)?;
2003    }
2004
2005    runtime.invalidate_result_cache();
2006    runtime.cdc_emit_insert_batch_no_cache_invalidate(&collection, &ids, "table");
2007
2008    Ok(ids
2009        .into_iter()
2010        .map(|id| CreateEntityOutput { id, entity: None })
2011        .collect())
2012}
2013
2014impl RuntimeEntityPort for RedDBRuntime {
2015    fn create_row(&self, input: CreateRowInput) -> RedDBResult<CreateEntityOutput> {
2016        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2017        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2018            self,
2019            &input.collection,
2020        );
2021        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2022        let db = self.db();
2023        let CreateRowInput {
2024            collection,
2025            fields,
2026            metadata: input_metadata,
2027            node_links,
2028            vector_links,
2029        } = input;
2030        let contract = CollectionContractWriteEnforcer::new(self, &db, &collection);
2031        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2032        let mut metadata = input_metadata;
2033        contract.apply_default_ttl(&mut metadata);
2034        let fields = contract.normalize_insert_fields(fields)?;
2035        contract.enforce_row_uniqueness(&fields, None)?;
2036        // Route through MutationEngine for unified hot path.
2037        let engine = self.mutation_engine();
2038        let result = engine.apply(
2039            collection.clone(),
2040            vec![crate::runtime::mutation::MutationRow {
2041                fields,
2042                metadata,
2043                node_links,
2044                vector_links,
2045            }],
2046        )?;
2047        let id = result.ids[0];
2048        // Perf: `db.get(id)` does a *cross-collection* scan (get_any)
2049        // that also takes a write lock on the entity cache. We know
2050        // the collection — hit the manager directly. Cuts
2051        // create_row() p50 roughly in half on the hot path.
2052        Ok(CreateEntityOutput {
2053            id,
2054            entity: db.store().get(&collection, id),
2055        })
2056    }
2057
2058    fn create_rows_batch(
2059        &self,
2060        input: CreateRowsBatchInput,
2061    ) -> RedDBResult<Vec<CreateEntityOutput>> {
2062        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2063            self,
2064            &input.collection,
2065        );
2066        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2067        if input.rows.is_empty() {
2068            return Ok(Vec::new());
2069        }
2070        self.check_batch_size(input.rows.len())?;
2071        self.check_db_size()?;
2072
2073        let db = self.db();
2074        let collection = input.collection;
2075        let suppress_events = input.suppress_events;
2076        let contract = CollectionContractWriteEnforcer::new(self, &db, &collection);
2077        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2078
2079        let mut prepared_rows = Vec::with_capacity(input.rows.len());
2080        let mut uniqueness_rows = Vec::with_capacity(input.rows.len());
2081        for row in input.rows {
2082            if row.collection != collection {
2083                return Err(crate::RedDBError::Query(format!(
2084                    "batch row collection mismatch: expected '{}', got '{}'",
2085                    collection, row.collection
2086                )));
2087            }
2088
2089            let mut metadata = row.metadata;
2090            contract.apply_default_ttl(&mut metadata);
2091            let fields = contract.normalize_insert_fields(row.fields)?;
2092            contract.enforce_row_uniqueness(&fields, None)?;
2093            uniqueness_rows.push(fields.clone());
2094            prepared_rows.push((fields, metadata, row.node_links, row.vector_links));
2095        }
2096
2097        contract.enforce_batch_uniqueness(&uniqueness_rows)?;
2098
2099        // Route through MutationEngine: single bulk_insert + one CDC batch
2100        // instead of N separate cdc_emit() calls (each acquires a write lock).
2101        let engine = {
2102            let e = self.mutation_engine();
2103            if suppress_events {
2104                e.with_suppress_events()
2105            } else {
2106                e
2107            }
2108        };
2109        let mutation_rows: Vec<crate::runtime::mutation::MutationRow> = prepared_rows
2110            .into_iter()
2111            .map(|(fields, metadata, node_links, vector_links)| {
2112                crate::runtime::mutation::MutationRow {
2113                    fields,
2114                    metadata,
2115                    node_links,
2116                    vector_links,
2117                }
2118            })
2119            .collect();
2120
2121        let result = engine.apply(collection.clone(), mutation_rows)?;
2122
2123        let store = db.store();
2124        Ok(result
2125            .ids
2126            .into_iter()
2127            .map(|id| CreateEntityOutput {
2128                id,
2129                entity: store.get(&collection, id),
2130            })
2131            .collect())
2132    }
2133
2134    fn create_rows_batch_prevalidated_columnar(
2135        &self,
2136        collection: String,
2137        column_names: std::sync::Arc<Vec<String>>,
2138        rows: Vec<Vec<reddb_types::Value>>,
2139    ) -> RedDBResult<usize> {
2140        create_rows_batch_prevalidated_columnar_with_outputs(self, collection, column_names, rows)
2141            .map(|outputs| outputs.len())
2142    }
2143
2144    fn create_rows_batch_columnar(
2145        &self,
2146        collection: String,
2147        column_names: std::sync::Arc<Vec<String>>,
2148        rows: Vec<Vec<reddb_types::Value>>,
2149    ) -> RedDBResult<usize> {
2150        self.create_rows_batch_columnar_with_outputs(collection, column_names, rows)
2151            .map(|outputs| outputs.len())
2152    }
2153
2154    fn create_rows_batch_columnar_with_outputs(
2155        &self,
2156        collection: String,
2157        column_names: std::sync::Arc<Vec<String>>,
2158        rows: Vec<Vec<reddb_types::Value>>,
2159    ) -> RedDBResult<Vec<CreateEntityOutput>> {
2160        if rows.is_empty() {
2161            return Ok(Vec::new());
2162        }
2163        self.check_batch_size(rows.len())?;
2164        self.check_db_size()?;
2165
2166        let db = self.db();
2167        let contract = CollectionContractWriteEnforcer::new(self, &db, &collection);
2168        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2169
2170        // Fast path: when the collection carries no contract (or the
2171        // contract has no declared columns) `normalize_insert_fields`
2172        // is a no-op and `enforce_row_uniqueness` finds no unique
2173        // columns to check. Skip the per-row `(String, Value)` tuple
2174        // materialisation entirely and route through the prevalidated
2175        // columnar kernel — same write, CDC, and index path, just
2176        // without the wasted (String, Value) clones. This is the
2177        // bench `bench_users` shape (no contract declared by the
2178        // adapter's `setup_schema`).
2179        let needs_normalisation = self.index_store_ref().has_unique_index(&collection)
2180            || match db.collection_contract(&collection) {
2181                Some(c) => {
2182                    c.declared_model == crate::catalog::CollectionModel::Table
2183                        && (!c.declared_columns.is_empty()
2184                            || c.table_def
2185                                .as_ref()
2186                                .map(|t| !t.columns.is_empty())
2187                                .unwrap_or(false))
2188                }
2189                None => false,
2190            };
2191        if !needs_normalisation {
2192            return create_rows_batch_prevalidated_columnar_with_outputs(
2193                self,
2194                collection,
2195                column_names,
2196                rows,
2197            );
2198        }
2199
2200        // Slow path: contract requires per-row normalisation /
2201        // uniqueness checks. Materialise `Vec<(String, Value)>` from
2202        // the columnar layout (this still pays N×ncols `String::clone`
2203        // — but it's deferred out of the wire decoder hot loop and
2204        // happens behind a runtime API, not the per-row decode loop)
2205        // and fall through to the existing `create_rows_batch` path.
2206        let ncols = column_names.len();
2207        let tuple_rows: Vec<CreateRowInput> = rows
2208            .into_iter()
2209            .map(|values| {
2210                let mut fields: Vec<(String, reddb_types::Value)> = Vec::with_capacity(ncols);
2211                for (name, value) in column_names.iter().zip(values) {
2212                    fields.push((name.clone(), value));
2213                }
2214                CreateRowInput {
2215                    collection: collection.clone(),
2216                    fields,
2217                    metadata: Vec::new(),
2218                    node_links: Vec::new(),
2219                    vector_links: Vec::new(),
2220                }
2221            })
2222            .collect();
2223        self.create_rows_batch(CreateRowsBatchInput {
2224            collection,
2225            rows: tuple_rows,
2226            suppress_events: false,
2227        })
2228    }
2229
2230    fn create_rows_batch_prevalidated(&self, input: CreateRowsBatchInput) -> RedDBResult<usize> {
2231        self.create_rows_batch_prevalidated_with_outputs(input)
2232            .map(|outputs| outputs.len())
2233    }
2234
2235    fn create_rows_batch_prevalidated_with_outputs(
2236        &self,
2237        input: CreateRowsBatchInput,
2238    ) -> RedDBResult<Vec<CreateEntityOutput>> {
2239        if input.rows.is_empty() {
2240            return Ok(Vec::new());
2241        }
2242        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2243        self.check_batch_size(input.rows.len())?;
2244        self.check_db_size()?;
2245
2246        let db = self.db();
2247        let collection = input.collection;
2248        let suppress_events = input.suppress_events;
2249        // Still verify the collection's declared model before we blast
2250        // rows at it — this one-off check is O(1), independent of
2251        // ncols, and catches schema-kind mismatches that the client
2252        // can't always see.
2253        let contract = CollectionContractWriteEnforcer::new(self, &db, &collection);
2254        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2255
2256        // Hoist the per-collection default TTL lookup out of the
2257        // per-row loop — it only depends on the collection, not on
2258        // individual rows, and the old path did one HashMap read per
2259        // row (25k reads for a 25k typed_insert bulk). Fetch it once,
2260        // apply to any row whose metadata doesn't already carry a TTL.
2261        let default_ttl_plan = contract.default_ttl_plan();
2262
2263        let mutation_rows: Vec<crate::runtime::mutation::MutationRow> =
2264            Vec::with_capacity(input.rows.len());
2265        let mut mutation_rows = mutation_rows;
2266        for row in input.rows {
2267            if row.collection != collection {
2268                return Err(crate::RedDBError::Query(format!(
2269                    "batch row collection mismatch: expected '{}', got '{}'",
2270                    collection, row.collection
2271                )));
2272            }
2273            let mut metadata = row.metadata;
2274            default_ttl_plan.apply(&mut metadata);
2275            mutation_rows.push(crate::runtime::mutation::MutationRow {
2276                fields: row.fields,
2277                metadata,
2278                node_links: row.node_links,
2279                vector_links: row.vector_links,
2280            });
2281        }
2282
2283        let engine = {
2284            let engine = self.mutation_engine();
2285            if suppress_events {
2286                engine.with_suppress_events()
2287            } else {
2288                engine
2289            }
2290        };
2291        let result = engine.apply(collection.clone(), mutation_rows)?;
2292        let store = db.store();
2293        Ok(result
2294            .ids
2295            .into_iter()
2296            .map(|id| CreateEntityOutput {
2297                id,
2298                entity: store.get(&collection, id),
2299            })
2300            .collect())
2301    }
2302
2303    fn create_node(&self, input: CreateNodeInput) -> RedDBResult<CreateEntityOutput> {
2304        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2305        crate::reserved_fields::ensure_no_reserved_public_item_fields(
2306            input.properties.iter().map(|(key, _)| key.as_str()),
2307            &format!("node '{}'", input.collection),
2308        )?;
2309        ensure_non_tree_reserved_metadata_entries(&input.metadata)?;
2310        self.create_node_unchecked(input)
2311    }
2312
2313    fn create_edge(&self, input: CreateEdgeInput) -> RedDBResult<CreateEntityOutput> {
2314        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2315        crate::reserved_fields::ensure_no_reserved_public_item_fields(
2316            input.properties.iter().map(|(key, _)| key.as_str()),
2317            &format!("edge '{}'", input.collection),
2318        )?;
2319        ensure_non_tree_structural_edge_label(&input.label)?;
2320        ensure_non_tree_reserved_metadata_entries(&input.metadata)?;
2321        self.create_edge_unchecked(input)
2322    }
2323
2324    fn create_vector(&self, input: CreateVectorInput) -> RedDBResult<CreateEntityOutput> {
2325        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2326        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Insert, &input.collection)?;
2327        let mut vector_data = crate::storage::VectorData::new(input.dense.clone());
2328        vector_data.content = input.content.clone();
2329        let candidate = crate::storage::UnifiedEntity::new(
2330            crate::storage::EntityId::new(0),
2331            crate::storage::EntityKind::Vector {
2332                collection: input.collection.clone(),
2333            },
2334            crate::storage::EntityData::Vector(vector_data),
2335        );
2336        ensure_insert_entity_rls(self, &input.collection, &candidate)?;
2337        let db = self.db();
2338        let contract = CollectionContractWriteEnforcer::new(self, &db, &input.collection);
2339        contract.ensure_model(crate::catalog::CollectionModel::Vector)?;
2340        contract.ensure_vector_dimension(input.dense.len())?;
2341        let mut metadata = input.metadata;
2342        contract.apply_default_ttl(&mut metadata);
2343        let mut builder = db.vector(&input.collection).dense(input.dense);
2344
2345        if let Some(content) = input.content {
2346            builder = builder.content(content);
2347        }
2348
2349        for (key, value) in metadata {
2350            builder = builder.metadata(key, value);
2351        }
2352
2353        if let Some(link_row) = input.link_row {
2354            builder = builder.link_to_table(link_row);
2355        }
2356
2357        if let Some(link_node) = input.link_node {
2358            builder = builder.link_to_node(link_node);
2359        }
2360
2361        // The transaction stamp must be present on the first published version.
2362        // Stamping after save exposed an xmin=0 vector to concurrent snapshots.
2363        let id = builder.save_with_xmin(self.current_xid())?;
2364        let dense_for_turbo = match db.store().get(&input.collection, id).map(|e| e.data) {
2365            Some(crate::storage::unified::EntityData::Vector(data)) => Some(data.dense.clone()),
2366            _ => None,
2367        };
2368        refresh_context_index(&db, &input.collection, id)?;
2369        // Issue #693 — vector.turbo write path. Order matters and
2370        // matches the write sequence the next slice (#673) will
2371        // attach kill-points to: WAL append → in-memory index update
2372        // → TurboExtent append. The legacy `vector` path is the
2373        // `None` branch and is untouched.
2374        if let (Some(state), Some(dense)) = (db.turbo_state(&input.collection), dense_for_turbo) {
2375            // Lazy populate so an INSERT after restart sees the
2376            // pre-restart vectors in the index before appending its
2377            // own.
2378            state.ensure_populated(&db.store(), &input.collection);
2379            // Issue #694 — named crash boundaries for #673.
2380            use crate::runtime::turbo_crash_inject::{fire, InjectionPoint};
2381            fire(InjectionPoint::BeforeWalFsync);
2382            let _ = db
2383                .store()
2384                .append_vector_insert_record(&input.collection, id.raw(), &dense);
2385            fire(InjectionPoint::BeforeIndexCommit);
2386            let mut index = state.index.lock();
2387            index.insert(id, dense.clone());
2388            // Mirror the encoded codes into the per-collection
2389            // TurboExtent when one is available. The bytes are the
2390            // 4-bit-packed code groups (`n_byte_groups`) plus the
2391            // little-endian L2 norm — same shape `BlockedCodeStorage`
2392            // appends in-memory. Failures are non-fatal in this
2393            // slice; durability of the extent is owned by #673.
2394            fire(InjectionPoint::BeforeExtentFsync);
2395            if let Some(extent) = state.extent.lock().as_mut() {
2396                let packed = index.encode_for_extent(&dense);
2397                let _ = extent.append(&packed);
2398            }
2399        }
2400        self.cdc_emit(
2401            crate::replication::cdc::ChangeOperation::Insert,
2402            &input.collection,
2403            id.raw(),
2404            "vector",
2405        );
2406        Ok(CreateEntityOutput {
2407            id,
2408            entity: db.store().get(&input.collection, id),
2409        })
2410    }
2411
2412    fn create_document(&self, input: CreateDocumentInput) -> RedDBResult<CreateEntityOutput> {
2413        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2414        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Insert, &input.collection)?;
2415        let db = self.db();
2416        let contract = CollectionContractWriteEnforcer::new(self, &db, &input.collection);
2417        contract.ensure_model(crate::catalog::CollectionModel::Document)?;
2418
2419        if let JsonValue::Object(ref map) = input.body {
2420            crate::reserved_fields::ensure_no_reserved_public_item_fields(
2421                map.keys().map(String::as_str),
2422                &format!("document '{}'", input.collection),
2423            )?;
2424        }
2425
2426        // Serialize the full body for the "body" field. Behind the
2427        // `storage.binary_document_body` flag (PRD-1398) this is the native binary
2428        // container; otherwise plain JSON. Reads decode either form to JSON.
2429        let mut fields: Vec<(String, reddb_types::Value)> = vec![(
2430            "body".to_string(),
2431            reddb_types::Value::Json(
2432                crate::json::to_vec(&input.body)
2433                    .map_err(|err| crate::RedDBError::Query(err.to_string()))?,
2434            ),
2435        )];
2436        crate::runtime::mutation::enforce_tenant_fields(self, &input.collection, &mut fields)?;
2437        let (field_names, field_values) =
2438            crate::runtime::impl_dml_support::pairwise_columns_values(&fields);
2439        let scoped_body =
2440            crate::runtime::impl_dml_support::find_document_body_json(&field_names, &field_values)?;
2441        let body_bytes = crate::document_body::serialize_document_body(
2442            &scoped_body,
2443            self.binary_document_body_enabled(),
2444        )?;
2445        if let Some((_, body)) = fields.iter_mut().find(|(name, _)| name == "body") {
2446            *body = reddb_types::Value::Json(body_bytes);
2447        }
2448        let mut row = crate::storage::RowData::new(Vec::new());
2449        row.named = Some(fields.iter().cloned().collect());
2450        let candidate = crate::storage::UnifiedEntity::new(
2451            crate::storage::EntityId::new(0),
2452            crate::storage::EntityKind::TableRow {
2453                table: std::sync::Arc::from(input.collection.as_str()),
2454                row_id: 0,
2455            },
2456            crate::storage::EntityData::Row(row),
2457        );
2458        ensure_insert_entity_rls(self, &input.collection, &candidate)?;
2459
2460        let mut metadata = input.metadata;
2461        contract.apply_default_ttl(&mut metadata);
2462        let columns: Vec<(&str, reddb_types::Value)> = fields
2463            .iter()
2464            .map(|(key, value)| (key.as_str(), value.clone()))
2465            .collect();
2466        let mut builder = db.row(&input.collection, columns);
2467
2468        for (key, value) in metadata {
2469            builder = builder.metadata(key, value);
2470        }
2471
2472        for node in input.node_links {
2473            builder = builder.link_to_node(node);
2474        }
2475
2476        for vector in input.vector_links {
2477            builder = builder.link_to_vector(vector);
2478        }
2479
2480        let id = builder.save()?;
2481        // Phase 1.1 MVCC universal: stamp xmin on the document.
2482        self.stamp_xmin_if_in_txn(&input.collection, id);
2483        self.index_store_ref()
2484            .index_entity_insert(&input.collection, id, &fields)
2485            .map_err(crate::RedDBError::Internal)?;
2486        refresh_context_index(&db, &input.collection, id)?;
2487        self.cdc_emit(
2488            crate::replication::cdc::ChangeOperation::Insert,
2489            &input.collection,
2490            id.raw(),
2491            "document",
2492        );
2493        Ok(CreateEntityOutput {
2494            id,
2495            entity: db.store().get(&input.collection, id),
2496        })
2497    }
2498
2499    fn create_kv(&self, input: CreateKvInput) -> RedDBResult<CreateEntityOutput> {
2500        crate::runtime::kv_write::create(self, input, None)
2501    }
2502
2503    fn create_timeseries_point(
2504        &self,
2505        input: CreateTimeSeriesPointInput,
2506    ) -> RedDBResult<CreateEntityOutput> {
2507        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2508        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Insert, &input.collection)?;
2509        let db = self.db();
2510        let contract = CollectionContractWriteEnforcer::new(self, &db, &input.collection);
2511        contract.ensure_model(crate::catalog::CollectionModel::TimeSeries)?;
2512
2513        let mut fields = vec![
2514            ("metric".to_string(), reddb_types::Value::text(input.metric)),
2515            ("value".to_string(), reddb_types::Value::Float(input.value)),
2516        ];
2517
2518        if let Some(timestamp_ns) = input.timestamp_ns {
2519            fields.push((
2520                "timestamp".to_string(),
2521                reddb_types::Value::UnsignedInteger(timestamp_ns),
2522            ));
2523        }
2524
2525        if !input.tags.is_empty() {
2526            let tags_json = JsonValue::Object(
2527                input
2528                    .tags
2529                    .into_iter()
2530                    .map(|(key, value)| (key, JsonValue::String(value)))
2531                    .collect(),
2532            );
2533            let tags_bytes = json_to_vec(&tags_json).map_err(|err| {
2534                crate::RedDBError::Query(format!("failed to serialize timeseries tags: {err}"))
2535            })?;
2536            fields.push(("tags".to_string(), reddb_types::Value::Json(tags_bytes)));
2537        }
2538
2539        let collection = input.collection;
2540        let id = self.insert_timeseries_point(&collection, fields, input.metadata)?;
2541        // Phase 1.1 MVCC universal: stamp xmin on the point so
2542        // concurrent range scans hide it until COMMIT.
2543        self.stamp_xmin_if_in_txn(&collection, id);
2544        refresh_context_index(&db, &collection, id)?;
2545
2546        Ok(CreateEntityOutput {
2547            id,
2548            entity: db.store().get(&collection, id),
2549        })
2550    }
2551
2552    fn get_kv(
2553        &self,
2554        collection: &str,
2555        key: &str,
2556    ) -> RedDBResult<Option<(reddb_types::Value, crate::storage::EntityId)>> {
2557        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Select, collection)?;
2558        let resolver = crate::runtime::kv_read::KvReadResolver::for_rest_kv(self, collection)?;
2559        let Some(record) = resolver.record(key)? else {
2560            return Ok(None);
2561        };
2562        let visible = self
2563            .db()
2564            .store()
2565            .get(collection, record.id)
2566            .is_some_and(|entity| {
2567                crate::runtime::rls_injection::entity_passes_rls_action(
2568                    self,
2569                    collection,
2570                    reddb_rql::ast::PolicyAction::Select,
2571                    &entity,
2572                )
2573            });
2574        Ok(visible.then_some((record.value.unwrap_or(Value::Null), record.id)))
2575    }
2576
2577    fn delete_kv(&self, collection: &str, key: &str) -> RedDBResult<bool> {
2578        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2579        crate::runtime::impl_kv::KvAtomicOps::new(self).delete(
2580            crate::catalog::CollectionModel::Kv,
2581            collection,
2582            key,
2583        )
2584    }
2585
2586    fn patch_entity(&self, input: PatchEntityInput) -> RedDBResult<CreateEntityOutput> {
2587        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2588        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Update, &input.collection)?;
2589        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2590            self,
2591            &input.collection,
2592        );
2593        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2594        let topology_lock = self
2595            .index_store_ref()
2596            .collection_topology_lock(&input.collection);
2597        let topology_guard = topology_lock.read();
2598        let PatchEntityInput {
2599            collection,
2600            id,
2601            payload,
2602            operations,
2603        } = input;
2604        let db = self.db();
2605        let store = db.store();
2606        let Some(manager) = store.get_collection(&collection) else {
2607            return Err(crate::RedDBError::NotFound(format!(
2608                "collection not found: {collection}"
2609            )));
2610        };
2611        let Some(entity) = manager.get(id) else {
2612            return Err(crate::RedDBError::NotFound(format!(
2613                "entity not found: {}",
2614                id.raw()
2615            )));
2616        };
2617        if !crate::runtime::rls_injection::entity_passes_rls_action(
2618            self,
2619            &collection,
2620            reddb_rql::ast::PolicyAction::Update,
2621            &entity,
2622        ) {
2623            return Err(crate::RedDBError::NotFound(format!(
2624                "entity not found: {}",
2625                id.raw()
2626            )));
2627        }
2628        self.apply_loaded_patch_entity(collection, entity, payload, operations, topology_guard)
2629    }
2630
2631    fn delete_entity(&self, input: DeleteEntityInput) -> RedDBResult<DeleteEntityOutput> {
2632        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2633        self.check_collection_privilege(reddb_rql::ast::PolicyAction::Delete, &input.collection)?;
2634        let constraint_lock = crate::application::collection_contract_enforcer::row_constraint_lock(
2635            self,
2636            &input.collection,
2637        );
2638        let _constraint_guard = constraint_lock.as_ref().map(|lock| lock.lock());
2639        let topology_lock = self
2640            .index_store_ref()
2641            .collection_topology_lock(&input.collection);
2642        let topology_guard = topology_lock.read();
2643        let store = self.db().store();
2644        // Snapshot row fields before delete so we can mirror the removal
2645        // into every secondary index. The fetch is best-effort: if the
2646        // entity is already gone, the delete below is a no-op anyway.
2647        let Some(entity) = store.get(&input.collection, input.id) else {
2648            return Ok(DeleteEntityOutput {
2649                deleted: false,
2650                id: input.id,
2651            });
2652        };
2653        if !crate::runtime::rls_injection::entity_passes_rls_action(
2654            self,
2655            &input.collection,
2656            reddb_rql::ast::PolicyAction::Delete,
2657            &entity,
2658        ) {
2659            return Ok(DeleteEntityOutput {
2660                deleted: false,
2661                id: input.id,
2662            });
2663        }
2664        let pre_delete_fields = entity_row_fields_snapshot(&entity);
2665        // Store delete first (source of truth). Crash between here and index removal
2666        // leaves the entity invisible to most queries but recoverable; the reverse
2667        // (remove index first, then crash) leaves the entity permanently orphaned.
2668        let deleted = store
2669            .delete(&input.collection, input.id)
2670            .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
2671        if deleted {
2672            #[cfg(test)]
2673            self.index_store_ref().mutation_test_hook(
2674                &input.collection,
2675                crate::runtime::MutationTestPhase::StoragePublished,
2676            );
2677            store.context_index().remove_entity(input.id);
2678            // Secondary index maintenance — surface only registry-shape
2679            // errors; missing-index removals are tolerated inside the call.
2680            if !pre_delete_fields.is_empty() {
2681                self.index_store_ref()
2682                    .index_entity_delete(&input.collection, input.id, &pre_delete_fields)
2683                    .map_err(crate::RedDBError::Internal)?;
2684            }
2685            drop(topology_guard);
2686            #[cfg(test)]
2687            self.index_store_ref().mutation_test_hook(
2688                &input.collection,
2689                crate::runtime::MutationTestPhase::BeforeEvents,
2690            );
2691            self.cdc_emit(
2692                crate::replication::cdc::ChangeOperation::Delete,
2693                &input.collection,
2694                input.id.raw(),
2695                "entity",
2696            );
2697        }
2698        Ok(DeleteEntityOutput {
2699            deleted,
2700            id: input.id,
2701        })
2702    }
2703}