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