Skip to main content

reddb_server/application/
ports_impls_entity.rs

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