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