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