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