Skip to main content

reddb_server/application/
ports_impls_entity.rs

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