Skip to main content

reddb_server/application/
ports_impls_entity.rs

1use std::collections::HashMap;
2
3use crate::application::entity::{
4    AppliedEntityMutation, CreateDocumentInput, CreateKvInput, CreateTimeSeriesPointInput,
5    RowUpdateColumnRule, RowUpdateContractPlan,
6};
7use crate::application::ttl_payload::{
8    has_internal_ttl_metadata, normalize_ttl_patch_operations, parse_top_level_ttl_metadata_entries,
9};
10use crate::json::{to_vec as json_to_vec, Value as JsonValue};
11use crate::storage::query::resolve_declared_data_type;
12use crate::storage::schema::{coerce as coerce_schema_value, DataType, Value};
13use crate::storage::unified::MetadataValue;
14
15use super::*;
16
17const TREE_METADATA_PREFIX: &str = "red.tree.";
18const TREE_CHILD_EDGE_LABEL: &str = "TREE_CHILD";
19
20fn apply_collection_default_ttl(
21    db: &crate::storage::unified::devx::RedDB,
22    collection: &str,
23    metadata: &mut Vec<(String, MetadataValue)>,
24) {
25    if has_internal_ttl_metadata(metadata) {
26        return;
27    }
28
29    let Some(default_ttl_ms) = db.collection_default_ttl_ms(collection) else {
30        return;
31    };
32
33    metadata.push((
34        "_ttl_ms".to_string(),
35        if default_ttl_ms <= i64::MAX as u64 {
36            MetadataValue::Int(default_ttl_ms as i64)
37        } else {
38            MetadataValue::Timestamp(default_ttl_ms)
39        },
40    ));
41}
42
43fn refresh_context_index(
44    db: &crate::storage::unified::devx::RedDB,
45    collection: &str,
46    id: crate::storage::EntityId,
47) -> RedDBResult<()> {
48    let store = db.store();
49    let Some(entity) = store.get(collection, id) else {
50        return Ok(());
51    };
52
53    store.context_index().index_entity(collection, &entity);
54    Ok(())
55}
56
57/// Pull `(name, value)` pairs for every indexable field on an entity.
58/// Returns empty if field names are not recoverable here, so secondary-index
59/// maintenance has nothing to act on. Used by the delete + update paths.
60pub(crate) fn entity_row_fields_snapshot(
61    entity: &crate::storage::UnifiedEntity,
62) -> Vec<(String, Value)> {
63    let mut fields = match &entity.data {
64        crate::storage::EntityData::Row(row) => {
65            if let Some(named) = &row.named {
66                named.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
67            } else if let Some(schema) = &row.schema {
68                schema
69                    .iter()
70                    .zip(row.columns.iter())
71                    .map(|(name, value)| (name.clone(), value.clone()))
72                    .collect()
73            } else {
74                Vec::new()
75            }
76        }
77        crate::storage::EntityData::Node(node) => node
78            .properties
79            .iter()
80            .map(|(k, v)| (k.clone(), v.clone()))
81            .collect(),
82        crate::storage::EntityData::Edge(edge) => edge
83            .properties
84            .iter()
85            .map(|(k, v)| (k.clone(), v.clone()))
86            .collect(),
87        _ => Vec::new(),
88    };
89    expand_binary_document_body_snapshot(&mut fields);
90    fields
91}
92
93fn expand_binary_document_body_snapshot(fields: &mut Vec<(String, Value)>) {
94    let Some(body_idx) = fields.iter().position(|(name, _)| name == "body") else {
95        return;
96    };
97    let Value::Json(bytes) = &fields[body_idx].1 else {
98        return;
99    };
100    let body_fields = crate::document_body::body_fields(bytes);
101    if let Some(body_json) = crate::document_body::decode_container_to_json(bytes) {
102        if let Ok(json_bytes) = crate::json::to_vec(&body_json) {
103            fields[body_idx].1 = Value::Json(json_bytes);
104        }
105    }
106    let Some(body_fields) = body_fields else {
107        return;
108    };
109    for (name, value) in body_fields {
110        if fields.iter().all(|(existing, _)| existing != &name) {
111            fields.push((name, value));
112        }
113    }
114}
115
116fn ensure_collection_model_contract(
117    db: &crate::storage::unified::devx::RedDB,
118    collection: &str,
119    requested_model: crate::catalog::CollectionModel,
120) -> RedDBResult<()> {
121    if let Some(contract) = db.collection_contract(collection) {
122        if !contract_enforces_model(&contract) {
123            return Ok(());
124        }
125        if collection_model_allows(contract.declared_model, requested_model) {
126            return Ok(());
127        }
128        // A collection that declares an EMBED policy (#1271/#1272) — or a
129        // VISION policy with an image-embedding output (#1275) — holds its
130        // rows' enrichment vectors in place: the CDC enrichment consumer
131        // attaches vectors into the same collection so `VECTOR SEARCH` over
132        // it surfaces the enriched rows. Permit vector writes on such a
133        // collection even when it is otherwise a strict table.
134        if requested_model == crate::catalog::CollectionModel::Vector
135            && contract
136                .ai_policy
137                .as_ref()
138                .is_some_and(|policy| policy.embed.is_some() || policy.vision.is_some())
139        {
140            return Ok(());
141        }
142        return Err(crate::RedDBError::InvalidOperation(format!(
143            "collection '{}' is declared as '{}' and does not allow '{}' writes",
144            collection,
145            collection_model_name(contract.declared_model),
146            collection_model_name(requested_model)
147        )));
148    }
149
150    let now = implicit_contract_unix_ms();
151    db.save_collection_contract(crate::physical::CollectionContract {
152        name: collection.to_string(),
153        declared_model: requested_model,
154        schema_mode: crate::catalog::SchemaMode::Dynamic,
155        origin: crate::physical::ContractOrigin::Implicit,
156        version: 1,
157        created_at_unix_ms: now,
158        updated_at_unix_ms: now,
159        default_ttl_ms: db.collection_default_ttl_ms(collection),
160        vector_dimension: None,
161        vector_metric: None,
162        context_index_fields: Vec::new(),
163        declared_columns: Vec::new(),
164        table_def: matches!(requested_model, crate::catalog::CollectionModel::Table)
165            .then(|| crate::storage::schema::TableDef::new(collection.to_string())),
166        timestamps_enabled: false,
167        context_index_enabled: false,
168        metrics_raw_retention_ms: None,
169        metrics_rollup_policies: Vec::new(),
170        metrics_tenant_identity: None,
171        metrics_namespace: None,
172        // Implicit contracts are created on first write — mutability
173        // is the default until the operator runs explicit DDL.
174        append_only: false,
175        subscriptions: Vec::new(),
176        analytics_config: Vec::new(),
177        session_key: None,
178        session_gap_ms: None,
179        retention_duration_ms: None,
180        analytical_storage: None,
181
182        ai_policy: None,
183    })
184    .map(|_| ())
185    .map_err(|err| crate::RedDBError::Internal(err.to_string()))
186}
187
188fn implicit_contract_unix_ms() -> u128 {
189    std::time::SystemTime::now()
190        .duration_since(std::time::UNIX_EPOCH)
191        .unwrap_or_default()
192        .as_millis()
193}
194
195fn collection_model_allows(
196    declared_model: crate::catalog::CollectionModel,
197    requested_model: crate::catalog::CollectionModel,
198) -> bool {
199    declared_model == requested_model || declared_model == crate::catalog::CollectionModel::Mixed
200}
201
202fn ensure_vector_dimension_contract(
203    db: &crate::storage::unified::devx::RedDB,
204    collection: &str,
205    actual_dimension: usize,
206) -> RedDBResult<()> {
207    let Some(expected_dimension) = db
208        .collection_contract(collection)
209        .and_then(|contract| contract.vector_dimension)
210    else {
211        return Ok(());
212    };
213    if expected_dimension == actual_dimension {
214        return Ok(());
215    }
216    Err(crate::RedDBError::Query(format!(
217        "vector dimension mismatch for collection '{collection}': expected {expected_dimension}, got {actual_dimension}"
218    )))
219}
220
221fn collection_model_name(model: crate::catalog::CollectionModel) -> &'static str {
222    match model {
223        crate::catalog::CollectionModel::Table => "table",
224        crate::catalog::CollectionModel::Document => "document",
225        crate::catalog::CollectionModel::Graph => "graph",
226        crate::catalog::CollectionModel::Vector => "vector",
227        crate::catalog::CollectionModel::Hll => "hll",
228        crate::catalog::CollectionModel::Sketch => "sketch",
229        crate::catalog::CollectionModel::Filter => "filter",
230        crate::catalog::CollectionModel::Kv => "kv",
231        crate::catalog::CollectionModel::Config => "config",
232        crate::catalog::CollectionModel::Vault => "vault",
233        crate::catalog::CollectionModel::Mixed => "mixed",
234        crate::catalog::CollectionModel::TimeSeries => "timeseries",
235        crate::catalog::CollectionModel::Queue => "queue",
236        crate::catalog::CollectionModel::Metrics => "metrics",
237    }
238}
239
240#[derive(Clone)]
241struct UniquenessRule {
242    name: String,
243    columns: Vec<String>,
244    primary_key: bool,
245}
246
247#[derive(Copy, Clone, PartialEq, Eq)]
248enum NormalizeMode {
249    /// First write for this row. Timestamps auto-filled from now on
250    /// both `created_at` and `updated_at`; user attempts to set
251    /// either column are rejected.
252    Insert,
253    /// Update/patch path. `created_at` is preserved from the existing
254    /// row (immutable after insert); `updated_at` is bumped to now.
255    /// User attempts to set either via the patch are rejected.
256    Update,
257}
258
259mod collection_contract_enforcement {
260    use super::*;
261
262    pub(super) struct CollectionContractWriteEnforcer<'a> {
263        db: &'a crate::storage::unified::devx::RedDB,
264        collection: &'a str,
265    }
266
267    impl<'a> CollectionContractWriteEnforcer<'a> {
268        pub(super) fn new(
269            db: &'a crate::storage::unified::devx::RedDB,
270            collection: &'a str,
271        ) -> Self {
272            Self { db, collection }
273        }
274
275        pub(super) fn ensure_model(
276            &self,
277            requested_model: crate::catalog::CollectionModel,
278        ) -> RedDBResult<()> {
279            ensure_collection_model_contract(self.db, self.collection, requested_model)
280        }
281
282        pub(super) fn normalize_insert_fields(
283            &self,
284            fields: Vec<(String, Value)>,
285        ) -> RedDBResult<Vec<(String, Value)>> {
286            normalize_row_fields_for_contract_with_mode(
287                self.db,
288                self.collection,
289                fields,
290                NormalizeMode::Insert,
291            )
292        }
293
294        pub(super) fn normalize_update_fields(
295            &self,
296            fields: Vec<(String, Value)>,
297        ) -> RedDBResult<Vec<(String, Value)>> {
298            normalize_row_fields_for_contract_with_mode(
299                self.db,
300                self.collection,
301                fields,
302                NormalizeMode::Update,
303            )
304        }
305
306        pub(super) fn enforce_row_uniqueness(
307            &self,
308            fields: &[(String, Value)],
309            exclude_id: Option<crate::storage::EntityId>,
310        ) -> RedDBResult<()> {
311            enforce_row_uniqueness(self.db, self.collection, fields, exclude_id)
312        }
313
314        pub(super) fn enforce_batch_uniqueness(
315            &self,
316            rows: &[Vec<(String, Value)>],
317        ) -> RedDBResult<()> {
318            enforce_row_batch_uniqueness(self.db, self.collection, rows)
319        }
320
321        pub(super) fn requires_uniqueness_check(&self, modified_columns: &[String]) -> bool {
322            row_update_requires_uniqueness_check(self.db, self.collection, modified_columns)
323        }
324    }
325}
326
327use collection_contract_enforcement::CollectionContractWriteEnforcer;
328
329fn normalize_row_fields_for_contract_with_mode(
330    db: &crate::storage::unified::devx::RedDB,
331    collection: &str,
332    fields: Vec<(String, Value)>,
333    mode: NormalizeMode,
334) -> RedDBResult<Vec<(String, Value)>> {
335    let Some(contract) = db.collection_contract(collection) else {
336        return Ok(fields);
337    };
338
339    if contract.declared_model != crate::catalog::CollectionModel::Table
340        || (contract.declared_columns.is_empty()
341            && contract
342                .table_def
343                .as_ref()
344                .map(|table| table.columns.is_empty())
345                .unwrap_or(true))
346    {
347        return Ok(fields);
348    }
349
350    // Capture the pre-normalize value of created_at (if present) so
351    // Update mode can preserve it. Also capture updated_at to detect
352    // user attempts to set it via the patch payload.
353    //
354    // Heuristic for Update mode: if fields ALREADY contains a
355    // `created_at` whose value matches the row's on-disk entity, the
356    // caller is the patch pipeline carrying forward an auto-populated
357    // column — not a user mutation. Allow pass-through in that case,
358    // then restore the original value at the end.
359    let existing_created_at = if contract.timestamps_enabled && mode == NormalizeMode::Update {
360        fields
361            .iter()
362            .find(|(n, _)| n == "created_at")
363            .map(|(_, v)| v.clone())
364    } else {
365        None
366    };
367
368    // Reject user attempts to set runtime-managed timestamp columns.
369    // On Insert we reject any mention; on Update we only reject when
370    // the patch pipeline handed us a NEW value (not the one we
371    // auto-populated during the last insert).
372    if contract.timestamps_enabled && mode == NormalizeMode::Insert {
373        for (name, _) in &fields {
374            if name == "created_at" || name == "updated_at" {
375                return Err(crate::RedDBError::Query(format!(
376                    "collection '{}' manages '{}' automatically — do not set it in INSERT",
377                    collection, name
378                )));
379            }
380        }
381    }
382
383    let mut provided = std::collections::BTreeMap::new();
384    for (name, value) in &fields {
385        provided.insert(name.clone(), value.clone());
386    }
387
388    let resolved_columns = resolved_contract_columns(&contract)?;
389    let declared_names: std::collections::BTreeSet<String> = resolved_columns
390        .iter()
391        .map(|column| column.name.clone())
392        .collect();
393    let unknown_fields: Vec<String> = fields
394        .iter()
395        .filter(|(name, _)| !declared_names.contains(name))
396        .map(|(name, _)| name.clone())
397        .collect();
398    if matches!(contract.schema_mode, crate::catalog::SchemaMode::Strict)
399        && !unknown_fields.is_empty()
400    {
401        return Err(crate::RedDBError::Query(format!(
402            "collection '{}' is strict and does not allow undeclared fields: {}",
403            collection,
404            unknown_fields.join(", ")
405        )));
406    }
407    let mut normalized = Vec::new();
408    let now_ms = current_unix_ms_u64();
409
410    for column in &resolved_columns {
411        match provided.remove(&column.name) {
412            Some(value) => {
413                // Runtime-managed columns on Update: always overwrite
414                // with the runtime's own value (preserved created_at
415                // or fresh updated_at). User mutations are silently
416                // discarded because we reject them earlier.
417                if contract.timestamps_enabled && mode == NormalizeMode::Update {
418                    match column.name.as_str() {
419                        "created_at" => {
420                            normalized.push((
421                                column.name.clone(),
422                                existing_created_at
423                                    .clone()
424                                    .unwrap_or(Value::UnsignedInteger(now_ms)),
425                            ));
426                            continue;
427                        }
428                        "updated_at" => {
429                            normalized.push((column.name.clone(), Value::UnsignedInteger(now_ms)));
430                            continue;
431                        }
432                        _ => {}
433                    }
434                }
435                normalized.push((
436                    column.name.clone(),
437                    normalize_contract_value(collection, column, value)?,
438                ));
439            }
440            None => {
441                // Runtime-managed timestamp columns: auto-fill with now
442                // when the contract opted in. Both get the same value on
443                // first insert so callers can order by either.
444                if contract.timestamps_enabled
445                    && (column.name == "created_at" || column.name == "updated_at")
446                {
447                    normalized.push((column.name.clone(), Value::UnsignedInteger(now_ms)));
448                    continue;
449                }
450                if let Some(default) = &column.default {
451                    normalized.push((
452                        column.name.clone(),
453                        coerce_contract_literal(collection, &column.name, column, default)?,
454                    ));
455                } else if column.not_null {
456                    return Err(crate::RedDBError::Query(format!(
457                        "missing required column '{}' for collection '{}'",
458                        column.name, collection
459                    )));
460                }
461            }
462        }
463    }
464
465    for (name, value) in fields {
466        if !declared_names.contains(&name) {
467            normalized.push((name, value));
468        }
469    }
470
471    Ok(normalized)
472}
473
474fn current_unix_ms_u64() -> u64 {
475    std::time::SystemTime::now()
476        .duration_since(std::time::UNIX_EPOCH)
477        .map(|d| d.as_millis() as u64)
478        .unwrap_or(0)
479}
480
481fn enforce_row_uniqueness(
482    db: &crate::storage::unified::devx::RedDB,
483    collection: &str,
484    fields: &[(String, Value)],
485    exclude_id: Option<crate::storage::EntityId>,
486) -> RedDBResult<()> {
487    let Some(contract) = db.collection_contract(collection) else {
488        return Ok(());
489    };
490    if contract.declared_model != crate::catalog::CollectionModel::Table
491        && contract.declared_model != crate::catalog::CollectionModel::Mixed
492    {
493        return Ok(());
494    }
495
496    let rules = resolved_uniqueness_rules(&contract);
497    if rules.is_empty() {
498        return Ok(());
499    }
500
501    let store = db.store();
502    let Some(manager) = store.get_collection(collection) else {
503        return Ok(());
504    };
505
506    let input_fields: std::collections::BTreeMap<String, Value> = fields.iter().cloned().collect();
507
508    for rule in &rules {
509        let mut expected_signatures = Vec::new();
510        let mut skip_rule = false;
511
512        for column in &rule.columns {
513            match input_fields.get(column) {
514                Some(Value::Null) | None if rule.primary_key => {
515                    return Err(crate::RedDBError::Query(format!(
516                        "primary key '{}' in collection '{}' requires non-null column '{}'",
517                        rule.name, collection, column
518                    )))
519                }
520                Some(Value::Null) | None => {
521                    skip_rule = true;
522                    break;
523                }
524                Some(value) => {
525                    expected_signatures.push((column.clone(), value_signature(value)));
526                }
527            }
528        }
529
530        if skip_rule {
531            continue;
532        }
533
534        for entity in manager.query_all(|_| true) {
535            if exclude_id.map(|id| id == entity.id).unwrap_or(false) {
536                continue;
537            }
538            let Some(existing_fields) = row_fields_from_entity(&entity) else {
539                continue;
540            };
541
542            let duplicate = expected_signatures.iter().all(|(column, expected)| {
543                existing_fields
544                    .get(column)
545                    .map(|value| value_signature(value) == *expected)
546                    .unwrap_or(false)
547            });
548
549            if duplicate {
550                let qualifier = if rule.primary_key {
551                    "primary key"
552                } else {
553                    "unique constraint"
554                };
555                return Err(crate::RedDBError::Query(format!(
556                    "{} '{}' violated on collection '{}' for columns [{}]",
557                    qualifier,
558                    rule.name,
559                    collection,
560                    rule.columns.join(", ")
561                )));
562            }
563        }
564    }
565
566    Ok(())
567}
568
569fn enforce_row_batch_uniqueness(
570    db: &crate::storage::unified::devx::RedDB,
571    collection: &str,
572    rows: &[Vec<(String, Value)>],
573) -> RedDBResult<()> {
574    let Some(contract) = db.collection_contract(collection) else {
575        return Ok(());
576    };
577    if contract.declared_model != crate::catalog::CollectionModel::Table
578        && contract.declared_model != crate::catalog::CollectionModel::Mixed
579    {
580        return Ok(());
581    }
582
583    let rules = resolved_uniqueness_rules(&contract);
584    if rules.is_empty() {
585        return Ok(());
586    }
587
588    for rule in &rules {
589        let mut seen = std::collections::HashMap::<String, usize>::new();
590        for (row_index, fields) in rows.iter().enumerate() {
591            let input_fields: std::collections::BTreeMap<String, Value> =
592                fields.iter().cloned().collect();
593            let mut signatures = Vec::new();
594            let mut skip_rule = false;
595
596            for column in &rule.columns {
597                match input_fields.get(column) {
598                    Some(Value::Null) | None if rule.primary_key => {
599                        return Err(crate::RedDBError::Query(format!(
600                            "primary key '{}' in collection '{}' requires non-null column '{}'",
601                            rule.name, collection, column
602                        )))
603                    }
604                    Some(Value::Null) | None => {
605                        skip_rule = true;
606                        break;
607                    }
608                    Some(value) => signatures.push(format!("{column}={}", value_signature(value))),
609                }
610            }
611
612            if skip_rule {
613                continue;
614            }
615
616            let signature = signatures.join("|");
617            if let Some(previous_index) = seen.insert(signature, row_index) {
618                return Err(crate::RedDBError::Query(format!(
619                    "batch insert violates uniqueness rule '{}' in collection '{}' between rows {} and {}",
620                    rule.name,
621                    collection,
622                    previous_index + 1,
623                    row_index + 1
624                )));
625            }
626        }
627    }
628
629    Ok(())
630}
631
632fn row_update_requires_uniqueness_check(
633    db: &crate::storage::unified::devx::RedDB,
634    collection: &str,
635    modified_columns: &[String],
636) -> bool {
637    if modified_columns.is_empty() {
638        return false;
639    }
640
641    let Some(contract) = db.collection_contract(collection) else {
642        return false;
643    };
644    if contract.declared_model != crate::catalog::CollectionModel::Table
645        && contract.declared_model != crate::catalog::CollectionModel::Mixed
646    {
647        return false;
648    }
649
650    let rules = resolved_uniqueness_rules(&contract);
651    if rules.is_empty() {
652        return false;
653    }
654
655    rules.iter().any(|rule| {
656        rule.columns.iter().any(|column| {
657            modified_columns
658                .iter()
659                .any(|modified| modified.eq_ignore_ascii_case(column))
660        })
661    })
662}
663
664pub(crate) fn build_row_update_contract_plan(
665    db: &crate::storage::unified::devx::RedDB,
666    collection: &str,
667) -> RedDBResult<Option<RowUpdateContractPlan>> {
668    let Some(contract) = db.collection_contract(collection) else {
669        return Ok(None);
670    };
671
672    let declared_rules = if contract.declared_model == crate::catalog::CollectionModel::Table
673        && !(contract.declared_columns.is_empty()
674            && contract
675                .table_def
676                .as_ref()
677                .map(|table| table.columns.is_empty())
678                .unwrap_or(true))
679    {
680        resolved_contract_columns(&contract)?
681            .into_iter()
682            .map(|rule| {
683                (
684                    rule.name.clone(),
685                    RowUpdateColumnRule {
686                        name: rule.name,
687                        data_type: rule.data_type,
688                        data_type_name: rule.data_type_name,
689                        not_null: rule.not_null,
690                        enum_variants: rule.enum_variants,
691                    },
692                )
693            })
694            .collect()
695    } else {
696        HashMap::new()
697    };
698
699    let unique_columns = if matches!(
700        contract.declared_model,
701        crate::catalog::CollectionModel::Table | crate::catalog::CollectionModel::Mixed
702    ) {
703        resolved_uniqueness_rules(&contract)
704            .into_iter()
705            .flat_map(|rule| rule.columns.into_iter())
706            .map(|column| (column, ()))
707            .collect()
708    } else {
709        HashMap::new()
710    };
711
712    Ok(Some(RowUpdateContractPlan {
713        timestamps_enabled: contract.timestamps_enabled,
714        strict_schema: matches!(contract.schema_mode, crate::catalog::SchemaMode::Strict),
715        declared_rules,
716        unique_columns,
717    }))
718}
719
720pub(crate) fn normalize_row_update_assignment_with_plan(
721    collection: &str,
722    column: &str,
723    value: Value,
724    row_contract_plan: Option<&RowUpdateContractPlan>,
725) -> RedDBResult<Value> {
726    let Some(plan) = row_contract_plan else {
727        return Ok(value);
728    };
729
730    if plan.timestamps_enabled && (column == "created_at" || column == "updated_at") {
731        return Err(crate::RedDBError::Query(format!(
732            "collection '{}' manages '{}' automatically — do not set it in UPDATE",
733            collection, column
734        )));
735    }
736
737    if let Some(rule) = plan.declared_rules.get(column) {
738        let rule = ResolvedColumnRule {
739            name: rule.name.clone(),
740            data_type: rule.data_type,
741            data_type_name: rule.data_type_name.clone(),
742            not_null: rule.not_null,
743            default: None,
744            enum_variants: rule.enum_variants.clone(),
745        };
746        normalize_contract_value(collection, &rule, value)
747    } else if plan.strict_schema {
748        Err(crate::RedDBError::Query(format!(
749            "collection '{}' is strict and does not allow undeclared fields: {}",
750            collection, column
751        )))
752    } else {
753        Ok(value)
754    }
755}
756
757pub(crate) fn normalize_row_update_value_for_rule(
758    collection: &str,
759    value: Value,
760    row_rule: Option<&RowUpdateColumnRule>,
761) -> RedDBResult<Value> {
762    let Some(rule) = row_rule else {
763        return Ok(value);
764    };
765
766    let rule = ResolvedColumnRule {
767        name: rule.name.clone(),
768        data_type: rule.data_type,
769        data_type_name: rule.data_type_name.clone(),
770        not_null: rule.not_null,
771        default: None,
772        enum_variants: rule.enum_variants.clone(),
773    };
774    normalize_contract_value(collection, &rule, value)
775}
776
777fn set_row_field(row: &mut crate::storage::unified::entity::RowData, name: &str, value: Value) {
778    if let Some(named) = row.named.as_mut() {
779        named.insert(name.to_string(), value);
780        return;
781    }
782
783    if let Some(schema) = row.schema.as_ref() {
784        if let Some(index) = schema.iter().position(|column| column == name) {
785            if let Some(slot) = row.columns.get_mut(index) {
786                *slot = value;
787                return;
788            }
789        }
790
791        let mut named = HashMap::with_capacity(schema.len().saturating_add(1));
792        for (column, current) in schema.iter().zip(row.columns.iter()) {
793            named.insert(column.clone(), current.clone());
794        }
795        named.insert(name.to_string(), value);
796        row.named = Some(named);
797        return;
798    }
799
800    let mut named = HashMap::with_capacity(1);
801    named.insert(name.to_string(), value);
802    row.named = Some(named);
803}
804
805fn collect_row_fields(row: &crate::storage::unified::entity::RowData) -> Vec<(String, Value)> {
806    if let Some(named) = row.named.as_ref() {
807        named
808            .iter()
809            .map(|(key, value)| (key.clone(), value.clone()))
810            .collect()
811    } else if let Some(schema) = row.schema.as_ref() {
812        schema
813            .iter()
814            .cloned()
815            .zip(row.columns.iter().cloned())
816            .collect()
817    } else {
818        Vec::new()
819    }
820}
821
822fn apply_row_field_assignments_raw<I>(
823    row: &mut crate::storage::unified::entity::RowData,
824    field_assignments: I,
825) where
826    I: IntoIterator<Item = (String, Value)>,
827{
828    for (column, value) in field_assignments {
829        set_row_field(row, &column, value);
830    }
831}
832
833fn apply_row_field_assignments_incremental<I>(
834    collection: &str,
835    row: &mut crate::storage::unified::entity::RowData,
836    field_assignments: I,
837    row_contract_plan: Option<&RowUpdateContractPlan>,
838) -> RedDBResult<()>
839where
840    I: IntoIterator<Item = (String, Value)>,
841{
842    for (column, value) in field_assignments {
843        let value = normalize_row_update_assignment_with_plan(
844            collection,
845            &column,
846            value,
847            row_contract_plan,
848        )?;
849
850        set_row_field(row, &column, value);
851    }
852
853    Ok(())
854}
855
856fn resolved_uniqueness_rules(
857    contract: &crate::physical::CollectionContract,
858) -> Vec<UniquenessRule> {
859    let mut rules = Vec::new();
860
861    if let Some(table_def) = &contract.table_def {
862        if !table_def.primary_key.is_empty() {
863            rules.push(UniquenessRule {
864                name: "primary_key".to_string(),
865                columns: table_def.primary_key.clone(),
866                primary_key: true,
867            });
868        }
869
870        for constraint in &table_def.constraints {
871            if matches!(
872                constraint.constraint_type,
873                crate::storage::schema::ConstraintType::PrimaryKey
874            ) && !constraint.columns.is_empty()
875            {
876                rules.push(UniquenessRule {
877                    name: constraint.name.clone(),
878                    columns: constraint.columns.clone(),
879                    primary_key: true,
880                });
881            } else if matches!(
882                constraint.constraint_type,
883                crate::storage::schema::ConstraintType::Unique
884            ) && !constraint.columns.is_empty()
885            {
886                rules.push(UniquenessRule {
887                    name: constraint.name.clone(),
888                    columns: constraint.columns.clone(),
889                    primary_key: false,
890                });
891            }
892        }
893    } else {
894        for column in &contract.declared_columns {
895            if column.primary_key {
896                rules.push(UniquenessRule {
897                    name: format!("pk_{}", column.name),
898                    columns: vec![column.name.clone()],
899                    primary_key: true,
900                });
901            } else if column.unique {
902                rules.push(UniquenessRule {
903                    name: format!("uniq_{}", column.name),
904                    columns: vec![column.name.clone()],
905                    primary_key: false,
906                });
907            }
908        }
909    }
910
911    let mut dedup = std::collections::BTreeSet::new();
912    rules
913        .into_iter()
914        .filter(|rule| dedup.insert((rule.primary_key, rule.columns.clone())))
915        .collect()
916}
917
918fn row_fields_from_entity(
919    entity: &crate::storage::UnifiedEntity,
920) -> Option<std::collections::BTreeMap<String, Value>> {
921    match &entity.data {
922        crate::storage::EntityData::Row(row) => {
923            if let Some(named) = &row.named {
924                Some(
925                    named
926                        .iter()
927                        .map(|(key, value)| (key.clone(), value.clone()))
928                        .collect(),
929                )
930            } else {
931                row.schema.as_ref().map(|schema| {
932                    schema
933                        .iter()
934                        .cloned()
935                        .zip(row.columns.iter().cloned())
936                        .collect()
937                })
938            }
939        }
940        _ => None,
941    }
942}
943
944fn value_signature(value: &Value) -> String {
945    format!("{value:?}")
946}
947
948fn normalize_contract_value(
949    collection: &str,
950    column: &ResolvedColumnRule,
951    value: Value,
952) -> RedDBResult<Value> {
953    if matches!(value, Value::Null) {
954        if column.not_null {
955            return Err(crate::RedDBError::Query(format!(
956                "column '{}' in collection '{}' cannot be null",
957                column.name, collection
958            )));
959        }
960        return Ok(Value::Null);
961    }
962
963    let target = column.data_type;
964    if value_matches_declared_type(&value, target) {
965        return Ok(value);
966    }
967
968    let Some(raw) = value_to_coercion_input(&value) else {
969        return Err(crate::RedDBError::Query(format!(
970            "column '{}' in collection '{}' requires type '{}' but value cannot be coerced",
971            column.name, collection, column.data_type_name
972        )));
973    };
974
975    coerce_contract_literal(collection, &column.name, column, &raw)
976}
977
978fn coerce_contract_literal(
979    collection: &str,
980    column_name: &str,
981    column: &ResolvedColumnRule,
982    raw: &str,
983) -> RedDBResult<Value> {
984    let target = column.data_type;
985    match target {
986        DataType::Blob => Ok(Value::Blob(raw.as_bytes().to_vec())),
987        // ADR 0067 (#1721): a JSON value is written as an inline strict-JSON
988        // literal — a bare string is not silently wrapped as JSON. Wrap a
989        // runtime string with `JSON_PARSE(<expr>)`. (An inline literal already
990        // arrives as `Value::Json` and never reaches this coercion.)
991        DataType::Json => Err(crate::RedDBError::Query(format!(
992            "column '{column_name}' in collection '{collection}' requires an inline strict-JSON \
993             literal (e.g. `{{\"k\": \"v\"}}`) or `JSON_PARSE(<expr>)`; a bare string is not \
994             coerced to JSON (ADR 0067)"
995        ))),
996        DataType::Timestamp => raw.parse::<i64>().map(Value::Timestamp).map_err(|err| {
997            crate::RedDBError::Query(format!(
998                "failed to coerce column '{}' in collection '{}' to '{}': {}",
999                column_name, collection, column.data_type_name, err
1000            ))
1001        }),
1002        DataType::Duration => raw.parse::<i64>().map(Value::Duration).map_err(|err| {
1003            crate::RedDBError::Query(format!(
1004                "failed to coerce column '{}' in collection '{}' to '{}': {}",
1005                column_name, collection, column.data_type_name, err
1006            ))
1007        }),
1008        DataType::Vector | DataType::Array => Err(crate::RedDBError::Query(format!(
1009            "column '{}' in collection '{}' requires '{}' and only typed values are accepted for this type",
1010            column_name, collection, column.data_type_name
1011        ))),
1012        _ => coerce_schema_value(raw, target, Some(column.enum_variants.as_slice())).map_err(
1013            |err| {
1014                crate::RedDBError::Query(format!(
1015                    "failed to coerce column '{}' in collection '{}' to '{}': {}",
1016                    column_name, collection, column.data_type_name, err
1017                ))
1018            },
1019        ),
1020    }
1021}
1022
1023struct ResolvedColumnRule {
1024    name: String,
1025    data_type: DataType,
1026    data_type_name: String,
1027    not_null: bool,
1028    default: Option<String>,
1029    enum_variants: Vec<String>,
1030}
1031
1032fn resolved_contract_columns(
1033    contract: &crate::physical::CollectionContract,
1034) -> RedDBResult<Vec<ResolvedColumnRule>> {
1035    if let Some(table_def) = &contract.table_def {
1036        return Ok(table_def
1037            .columns
1038            .iter()
1039            .map(|column| ResolvedColumnRule {
1040                name: column.name.clone(),
1041                data_type: column.data_type,
1042                data_type_name: data_type_name(column.data_type).to_string(),
1043                not_null: !column.nullable,
1044                default: column
1045                    .default
1046                    .as_ref()
1047                    .map(|bytes| String::from_utf8_lossy(bytes).to_string()),
1048                enum_variants: column.enum_variants.clone(),
1049            })
1050            .collect());
1051    }
1052
1053    contract
1054        .declared_columns
1055        .iter()
1056        .map(|column| {
1057            let data_type = column
1058                .sql_type
1059                .as_ref()
1060                .map(crate::storage::query::resolve_sql_type_name)
1061                .transpose()
1062                .map_err(|err| crate::RedDBError::Query(err.to_string()))?
1063                .unwrap_or(parse_declared_data_type(&column.data_type)?);
1064            Ok(ResolvedColumnRule {
1065                name: column.name.clone(),
1066                data_type,
1067                data_type_name: column.data_type.clone(),
1068                not_null: column.not_null,
1069                default: column.default.clone(),
1070                enum_variants: column.enum_variants.clone(),
1071            })
1072        })
1073        .collect()
1074}
1075
1076fn parse_declared_data_type(value: &str) -> RedDBResult<DataType> {
1077    resolve_declared_data_type(value).map_err(|err| crate::RedDBError::Query(err.to_string()))
1078}
1079
1080fn data_type_name(data_type: DataType) -> &'static str {
1081    match data_type {
1082        DataType::Integer => "integer",
1083        DataType::UnsignedInteger => "unsigned_integer",
1084        DataType::Float => "float",
1085        DataType::Text => "text",
1086        DataType::Blob => "blob",
1087        DataType::Boolean => "boolean",
1088        DataType::Timestamp => "timestamp",
1089        DataType::Duration => "duration",
1090        DataType::IpAddr => "ipaddr",
1091        DataType::MacAddr => "macaddr",
1092        DataType::Vector => "vector",
1093        DataType::Nullable => "nullable",
1094        DataType::Unknown => "unknown",
1095        DataType::Json => "json",
1096        DataType::Uuid => "uuid",
1097        DataType::NodeRef => "noderef",
1098        DataType::EdgeRef => "edgeref",
1099        DataType::VectorRef => "vectorref",
1100        DataType::RowRef => "rowref",
1101        DataType::Color => "color",
1102        DataType::Email => "email",
1103        DataType::Url => "url",
1104        DataType::Phone => "phone",
1105        DataType::Semver => "semver",
1106        DataType::Cidr => "cidr",
1107        DataType::Date => "date",
1108        DataType::Time => "time",
1109        DataType::Decimal => "decimal",
1110        DataType::DecimalText => "decimal_text",
1111        DataType::Enum => "enum",
1112        DataType::Array => "array",
1113        DataType::TimestampMs => "timestamp_ms",
1114        DataType::Ipv4 => "ipv4",
1115        DataType::Ipv6 => "ipv6",
1116        DataType::Subnet => "subnet",
1117        DataType::Port => "port",
1118        DataType::Latitude => "latitude",
1119        DataType::Longitude => "longitude",
1120        DataType::GeoPoint => "geopoint",
1121        DataType::Country2 => "country2",
1122        DataType::Country3 => "country3",
1123        DataType::Lang2 => "lang2",
1124        DataType::Lang5 => "lang5",
1125        DataType::Currency => "currency",
1126        DataType::AssetCode => "asset_code",
1127        DataType::Money => "money",
1128        DataType::ColorAlpha => "color_alpha",
1129        DataType::BigInt => "bigint",
1130        DataType::KeyRef => "keyref",
1131        DataType::DocRef => "docref",
1132        DataType::TableRef => "tableref",
1133        DataType::PageRef => "pageref",
1134        DataType::Secret => "secret",
1135        DataType::Password => "password",
1136        DataType::TextZstd => "text",
1137        DataType::BlobZstd => "blob",
1138    }
1139}
1140
1141fn value_matches_declared_type(value: &Value, target: DataType) -> bool {
1142    matches!(
1143        (value, target),
1144        (Value::Null, _)
1145            | (Value::Integer(_), DataType::Integer)
1146            | (Value::UnsignedInteger(_), DataType::UnsignedInteger)
1147            | (Value::Float(_), DataType::Float)
1148            | (Value::Text(_), DataType::Text)
1149            | (Value::Blob(_), DataType::Blob)
1150            | (Value::Boolean(_), DataType::Boolean)
1151            | (Value::Timestamp(_), DataType::Timestamp)
1152            | (Value::Duration(_), DataType::Duration)
1153            | (Value::IpAddr(_), DataType::IpAddr)
1154            | (Value::MacAddr(_), DataType::MacAddr)
1155            | (Value::Vector(_), DataType::Vector)
1156            | (Value::Json(_), DataType::Json)
1157            | (Value::Uuid(_), DataType::Uuid)
1158            | (Value::NodeRef(_), DataType::NodeRef)
1159            | (Value::EdgeRef(_), DataType::EdgeRef)
1160            | (Value::VectorRef(_, _), DataType::VectorRef)
1161            | (Value::RowRef(_, _), DataType::RowRef)
1162            | (Value::Color(_), DataType::Color)
1163            | (Value::Email(_), DataType::Email)
1164            | (Value::Url(_), DataType::Url)
1165            | (Value::Phone(_), DataType::Phone)
1166            | (Value::Semver(_), DataType::Semver)
1167            | (Value::Cidr(_, _), DataType::Cidr)
1168            | (Value::Date(_), DataType::Date)
1169            | (Value::Time(_), DataType::Time)
1170            | (Value::Decimal(_), DataType::Decimal)
1171            | (Value::DecimalText(_), DataType::DecimalText)
1172            | (Value::EnumValue(_), DataType::Enum)
1173            | (Value::Array(_), DataType::Array)
1174            | (Value::TimestampMs(_), DataType::TimestampMs)
1175            | (Value::Ipv4(_), DataType::Ipv4)
1176            | (Value::Ipv6(_), DataType::Ipv6)
1177            | (Value::Subnet(_, _), DataType::Subnet)
1178            | (Value::Port(_), DataType::Port)
1179            | (Value::Latitude(_), DataType::Latitude)
1180            | (Value::Longitude(_), DataType::Longitude)
1181            | (Value::GeoPoint(_, _), DataType::GeoPoint)
1182            | (Value::Country2(_), DataType::Country2)
1183            | (Value::Country3(_), DataType::Country3)
1184            | (Value::Lang2(_), DataType::Lang2)
1185            | (Value::Lang5(_), DataType::Lang5)
1186            | (Value::Currency(_), DataType::Currency)
1187            | (Value::ColorAlpha(_), DataType::ColorAlpha)
1188            | (Value::BigInt(_), DataType::BigInt)
1189            | (Value::KeyRef(_, _), DataType::KeyRef)
1190            | (Value::DocRef(_, _), DataType::DocRef)
1191            | (Value::TableRef(_), DataType::TableRef)
1192            | (Value::PageRef(_), DataType::PageRef)
1193            | (Value::Secret(_), DataType::Secret)
1194            | (Value::Password(_), DataType::Password)
1195    )
1196}
1197
1198fn value_to_coercion_input(value: &Value) -> Option<String> {
1199    match value {
1200        Value::Null => None,
1201        Value::Integer(value) => Some(value.to_string()),
1202        Value::UnsignedInteger(value) => Some(value.to_string()),
1203        Value::Float(value) => Some(value.to_string()),
1204        Value::Text(value) => Some(value.to_string()),
1205        Value::Blob(value) => String::from_utf8(value.clone()).ok(),
1206        Value::Boolean(value) => Some(value.to_string()),
1207        Value::Timestamp(value) => Some(value.to_string()),
1208        Value::Duration(value) => Some(value.to_string()),
1209        Value::IpAddr(value) => Some(value.to_string()),
1210        Value::MacAddr(value) => Some(format!(
1211            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
1212            value[0], value[1], value[2], value[3], value[4], value[5]
1213        )),
1214        Value::Json(value) => Some(String::from_utf8_lossy(value).to_string()),
1215        Value::Email(value) => Some(value.clone()),
1216        Value::Url(value) => Some(value.clone()),
1217        Value::Phone(value) => Some(value.to_string()),
1218        Value::Semver(value) => Some(format!(
1219            "{}.{}.{}",
1220            value / 1_000_000,
1221            (value / 1_000) % 1_000,
1222            value % 1_000
1223        )),
1224        Value::Date(value) => Some(value.to_string()),
1225        Value::Time(value) => Some(value.to_string()),
1226        Value::Decimal(value) => Some(value.to_string()),
1227        Value::DecimalText(value) => Some(value.clone()),
1228        Value::TimestampMs(value) => Some(value.to_string()),
1229        Value::Ipv4(value) => Some(format!(
1230            "{}.{}.{}.{}",
1231            (value >> 24) & 0xFF,
1232            (value >> 16) & 0xFF,
1233            (value >> 8) & 0xFF,
1234            value & 0xFF
1235        )),
1236        Value::Port(value) => Some(value.to_string()),
1237        Value::Latitude(value) => Some((*value as f64 / 1_000_000.0).to_string()),
1238        Value::Longitude(value) => Some((*value as f64 / 1_000_000.0).to_string()),
1239        Value::GeoPoint(lat, lon) => Some(format!(
1240            "{},{}",
1241            *lat as f64 / 1_000_000.0,
1242            *lon as f64 / 1_000_000.0
1243        )),
1244        Value::BigInt(value) => Some(value.to_string()),
1245        Value::TableRef(value) => Some(value.clone()),
1246        Value::PageRef(value) => Some(value.to_string()),
1247        Value::Password(value) => Some(value.clone()),
1248        _ => None,
1249    }
1250}
1251
1252fn dedupe_modified_columns(mut modified_columns: Vec<String>) -> Vec<String> {
1253    if modified_columns.is_empty() {
1254        return modified_columns;
1255    }
1256
1257    let mut unique = Vec::with_capacity(modified_columns.len());
1258    for column in modified_columns.drain(..) {
1259        if !unique
1260            .iter()
1261            .any(|existing: &String| existing.eq_ignore_ascii_case(&column))
1262        {
1263            unique.push(column);
1264        }
1265    }
1266    unique
1267}
1268
1269fn reject_document_array_position_path(path: &[String]) -> RedDBResult<()> {
1270    if path.iter().any(|segment| segment.parse::<usize>().is_ok()) {
1271        return Err(crate::RedDBError::Query(
1272            "array positional document patch paths are unsupported; replace the array or full document body instead"
1273                .to_string(),
1274        ));
1275    }
1276    Ok(())
1277}
1278
1279/// Reject a document body `set` whose path would traverse *through* an existing
1280/// non-object value (e.g. `SET a.b = …` where `a` is already a scalar). This
1281/// mirrors the HTTP JSON-patch contract: intermediate objects are created only
1282/// where a slot is absent, never by silently clobbering a scalar. `body` is the
1283/// pre-merge document body.
1284fn reject_document_patch_path_through_scalar(body: &JsonValue, path: &[String]) -> RedDBResult<()> {
1285    if path.len() < 2 {
1286        return Ok(());
1287    }
1288    let scalar_error = |prefix: &str| {
1289        crate::RedDBError::Query(format!(
1290            "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",
1291            path.join("."),
1292            prefix
1293        ))
1294    };
1295    let mut current = body;
1296    // Walk the parent path (every segment but the leaf). Each container we index
1297    // into must be an object; an absent slot is fine (the merge creates it).
1298    for (idx, segment) in path[..path.len() - 1].iter().enumerate() {
1299        let JsonValue::Object(map) = current else {
1300            return Err(scalar_error(&path[..idx].join(".")));
1301        };
1302        match map.get(segment) {
1303            Some(next) => current = next,
1304            None => return Ok(()),
1305        }
1306    }
1307    // `current` is now the leaf's parent container; it must be an object.
1308    if !matches!(current, JsonValue::Object(_)) {
1309        return Err(scalar_error(&path[..path.len() - 1].join(".")));
1310    }
1311    Ok(())
1312}
1313
1314/// Apply document body `set`/`unset` operations, enforcing the shared document
1315/// patch contract first: array-positional paths and paths that traverse through
1316/// a scalar are rejected with clear errors before any mutation touches `body`.
1317/// Used by both the SQL nested-`SET` executor and the HTTP JSON-patch operation
1318/// path so the two surfaces stay consistent (ADR 0067).
1319fn apply_document_body_patch_operations(
1320    body: &mut JsonValue,
1321    operations: &[PatchEntityOperation],
1322) -> RedDBResult<()> {
1323    for op in operations {
1324        reject_document_array_position_path(&op.path)?;
1325        if matches!(op.op, PatchEntityOperationType::Set) {
1326            reject_document_patch_path_through_scalar(body, &op.path)?;
1327        }
1328    }
1329    apply_patch_operations_to_json(body, operations).map_err(crate::RedDBError::Query)
1330}
1331
1332fn document_body_from_named(fields: &HashMap<String, Value>) -> RedDBResult<JsonValue> {
1333    match fields.get("body") {
1334        Some(Value::Json(bytes)) => {
1335            // The stored body may be the native binary container (PRD-1398) or
1336            // plain JSON; decode either form back to JSON.
1337            if let Some(body) = crate::document_body::decode_container_to_json(bytes) {
1338                Ok(body)
1339            } else {
1340                crate::json::from_slice(bytes).map_err(|err| {
1341                    crate::RedDBError::Query(format!("failed to decode document body: {err}"))
1342                })
1343            }
1344        }
1345        Some(_) => Err(crate::RedDBError::Query(
1346            "document body field must contain JSON".to_string(),
1347        )),
1348        None => Ok(JsonValue::Object(Default::default())),
1349    }
1350}
1351
1352fn document_body_from_assignment(value: &Value) -> RedDBResult<JsonValue> {
1353    match value {
1354        Value::Json(bytes) | Value::Blob(bytes) => crate::json::from_slice(bytes)
1355            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1356        Value::Text(text) => crate::json::from_str(text.as_ref())
1357            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1358        Value::Integer(value) => crate::json::from_str(&value.to_string())
1359            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1360        Value::UnsignedInteger(value) => crate::json::from_str(&value.to_string())
1361            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1362        Value::Float(value) => crate::json::from_str(&value.to_string())
1363            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1364        other => Err(crate::RedDBError::Query(format!(
1365            "column 'body' expected JSON body, got {other:?}"
1366        ))),
1367    }
1368}
1369
1370fn document_body_set_operation(column: &str, value: Value) -> PatchEntityOperation {
1371    PatchEntityOperation {
1372        op: PatchEntityOperationType::Set,
1373        path: column.split('.').map(str::to_string).collect(),
1374        value: Some(crate::presentation::entity_json::storage_value_to_json(
1375            &value,
1376        )),
1377    }
1378}
1379
1380fn replace_document_fields_body(
1381    fields: &mut HashMap<String, Value>,
1382    body: JsonValue,
1383    binary: bool,
1384    modified_columns: &mut Vec<String>,
1385) -> RedDBResult<()> {
1386    modified_columns.push("body".to_string());
1387
1388    let old_keys: Vec<String> = fields
1389        .keys()
1390        .filter(|key| key.as_str() != "body")
1391        .cloned()
1392        .collect();
1393    for key in old_keys {
1394        fields.remove(&key);
1395        modified_columns.push(key);
1396    }
1397
1398    let body_bytes = crate::document_body::serialize_document_body(&body, binary)?;
1399    fields.insert("body".to_string(), Value::Json(body_bytes));
1400
1401    Ok(())
1402}
1403
1404fn ensure_no_reserved_document_body_fields(body: &JsonValue, collection: &str) -> RedDBResult<()> {
1405    if let JsonValue::Object(map) = body {
1406        crate::reserved_fields::ensure_no_reserved_public_item_fields(
1407            map.keys().map(String::as_str),
1408            &format!("document '{collection}'"),
1409        )?;
1410    }
1411    Ok(())
1412}
1413
1414fn replace_document_row_body(
1415    row: &mut crate::storage::unified::entity::RowData,
1416    body: JsonValue,
1417    collection: &str,
1418    binary: bool,
1419    modified_columns: &mut Vec<String>,
1420) -> RedDBResult<()> {
1421    ensure_no_reserved_document_body_fields(&body, collection)?;
1422    let fields = row.named.get_or_insert_with(Default::default);
1423    replace_document_fields_body(fields, body, binary, modified_columns)?;
1424
1425    // Full body replacement rebuilds the document from the canonical `body`.
1426    // Stale positional/schema values would otherwise leak back through JSON
1427    // presentation even after the named map was pruned.
1428    row.columns.clear();
1429    row.schema = None;
1430    Ok(())
1431}
1432
1433fn document_merge_patch_payload(payload: &JsonValue) -> &JsonValue {
1434    if let JsonValue::Object(object) = payload {
1435        if object.len() == 1 {
1436            if let Some(body) = object.get("body") {
1437                return body;
1438            }
1439        }
1440    }
1441    payload
1442}
1443
1444fn apply_document_json_merge_patch(body: &mut JsonValue, patch: &JsonValue) -> RedDBResult<()> {
1445    if !matches!(patch, JsonValue::Object(_)) {
1446        *body = patch.clone();
1447        return Ok(());
1448    }
1449
1450    if matches!(patch, JsonValue::Object(object) if object.is_empty()) {
1451        if !matches!(body, JsonValue::Object(_)) {
1452            *body = JsonValue::Object(Default::default());
1453        }
1454        return Ok(());
1455    }
1456
1457    let mut operations = Vec::new();
1458    collect_document_json_merge_patch_operations(body, Vec::new(), patch, &mut operations);
1459    apply_patch_operations_to_json(body, &operations).map_err(crate::RedDBError::Query)
1460}
1461
1462fn collect_document_json_merge_patch_operations(
1463    target: &JsonValue,
1464    path: Vec<String>,
1465    patch: &JsonValue,
1466    operations: &mut Vec<PatchEntityOperation>,
1467) {
1468    let JsonValue::Object(patch_object) = patch else {
1469        operations.push(PatchEntityOperation {
1470            op: PatchEntityOperationType::Set,
1471            path,
1472            value: Some(patch.clone()),
1473        });
1474        return;
1475    };
1476
1477    for (key, value) in patch_object {
1478        let mut child_path = path.clone();
1479        child_path.push(key.clone());
1480        match value {
1481            JsonValue::Null => operations.push(PatchEntityOperation {
1482                op: PatchEntityOperationType::Unset,
1483                path: child_path,
1484                value: None,
1485            }),
1486            JsonValue::Object(object) if object.is_empty() => {
1487                let child_target = match target {
1488                    JsonValue::Object(target_object) => target_object.get(key),
1489                    _ => None,
1490                };
1491                if !matches!(child_target, Some(JsonValue::Object(_))) {
1492                    operations.push(PatchEntityOperation {
1493                        op: PatchEntityOperationType::Set,
1494                        path: child_path,
1495                        value: Some(JsonValue::Object(Default::default())),
1496                    });
1497                }
1498            }
1499            JsonValue::Object(_) => {
1500                let child_target = match target {
1501                    JsonValue::Object(target_object) => target_object.get(key),
1502                    _ => None,
1503                }
1504                .unwrap_or(&JsonValue::Null);
1505                collect_document_json_merge_patch_operations(
1506                    child_target,
1507                    child_path,
1508                    value,
1509                    operations,
1510                );
1511            }
1512            _ => operations.push(PatchEntityOperation {
1513                op: PatchEntityOperationType::Set,
1514                path: child_path,
1515                value: Some(value.clone()),
1516            }),
1517        }
1518    }
1519}
1520
1521impl RedDBRuntime {
1522    pub(crate) fn apply_loaded_patch_entity_core(
1523        &self,
1524        collection: String,
1525        mut entity: crate::storage::UnifiedEntity,
1526        payload: JsonValue,
1527        operations: Vec<PatchEntityOperation>,
1528    ) -> RedDBResult<AppliedEntityMutation> {
1529        let id = entity.id;
1530        let previous_xmax = entity.xmax;
1531        let operations = normalize_ttl_patch_operations(operations)?;
1532        // Snapshot pre-patch row fields for the secondary-index hook —
1533        // empty for non-row entities, which is the desired no-op.
1534        let pre_mutation_fields = entity_row_fields_snapshot(&entity);
1535
1536        // Versioned collections retain MVCC history: a PATCH/UPDATE must
1537        // produce a NEW full version (the merged document/row/node) with
1538        // the prior version tombstoned, mirroring the SQL UPDATE row
1539        // path. Gated STRICTLY on the collection `versioned` flag —
1540        // non-versioned collections keep last-writer-wins in-place
1541        // mutation. Row entities (documents / table rows / KV) carry
1542        // logical-id MVCC history (Phase 1/2); graph nodes & edges join
1543        // in Phase 3. The post-image re-stamp (new physical id, same
1544        // logical_id, fresh xmin) at the tail of this function is fully
1545        // kind-agnostic, so the same machinery installs a versioned
1546        // graph-node update.
1547        let patch_versions = matches!(
1548            entity.data,
1549            crate::storage::EntityData::Row(_)
1550                | crate::storage::EntityData::Node(_)
1551                | crate::storage::EntityData::Edge(_)
1552        ) && self.vcs_is_versioned(&collection).unwrap_or(false);
1553        let versioned_update_xid = if patch_versions {
1554            match self.current_xid() {
1555                Some(xid) => Some(xid),
1556                None => {
1557                    let snapshot_manager = self.snapshot_manager();
1558                    let xid = snapshot_manager.begin();
1559                    snapshot_manager.commit(xid);
1560                    Some(xid)
1561                }
1562            }
1563        } else {
1564            None
1565        };
1566        let mut replaced_entity = versioned_update_xid.map(|xid| {
1567            let mut old = entity.clone();
1568            old.set_xmax(xid);
1569            old
1570        });
1571
1572        let db = self.db();
1573        let store = db.store();
1574        let Some(manager) = store.get_collection(&collection) else {
1575            return Err(crate::RedDBError::NotFound(format!(
1576                "collection not found: {collection}"
1577            )));
1578        };
1579
1580        let mut patch_metadata: Option<crate::storage::unified::Metadata> = None;
1581        let mut metadata_changed = false;
1582        let mut modified_columns: Vec<String> = Vec::new();
1583        let mut context_index_dirty = false;
1584        let mut graph_node_type: Option<String> = None;
1585        let mut graph_edge_weight: Option<f32> = None;
1586
1587        let row_contract_timestamps = db
1588            .collection_contract(&collection)
1589            .map(|c| c.timestamps_enabled)
1590            .unwrap_or(false);
1591
1592        match &mut entity.data {
1593            crate::storage::EntityData::Row(row) => {
1594                let is_document_collection = db
1595                    .collection_contract(&collection)
1596                    .map(|contract| {
1597                        contract.declared_model == crate::catalog::CollectionModel::Document
1598                    })
1599                    .unwrap_or(false);
1600                let binary_body = self.binary_document_body_enabled();
1601                let mut field_ops = Vec::new();
1602                let mut metadata_ops = Vec::new();
1603                let mut document_body_ops = Vec::new();
1604                let mut document_body_replace: Option<JsonValue> = None;
1605                let has_patch_operations = !operations.is_empty();
1606
1607                for mut op in operations {
1608                    let Some(root) = op.path.first().map(String::as_str) else {
1609                        return Err(crate::RedDBError::Query(
1610                            "patch path cannot be empty".to_string(),
1611                        ));
1612                    };
1613
1614                    match root {
1615                        "body" if is_document_collection => {
1616                            if op.path.len() == 1 {
1617                                match op.op {
1618                                    PatchEntityOperationType::Set
1619                                    | PatchEntityOperationType::Replace => {
1620                                        let Some(value) = op.value.take() else {
1621                                            return Err(crate::RedDBError::Query(
1622                                                "document body replacement requires a value"
1623                                                    .to_string(),
1624                                            ));
1625                                        };
1626                                        document_body_replace = Some(value);
1627                                    }
1628                                    PatchEntityOperationType::Unset => {
1629                                        return Err(crate::RedDBError::Query(
1630                                            "document body cannot be unset; replace it instead"
1631                                                .to_string(),
1632                                        ));
1633                                    }
1634                                }
1635                                continue;
1636                            }
1637                            if op.path.len() < 2 {
1638                                return Err(crate::RedDBError::Query(
1639                                    "document body patch paths require a nested key; use payload.body for full replacement"
1640                                        .to_string(),
1641                                ));
1642                            }
1643                            op.path.remove(0);
1644                            reject_document_array_position_path(&op.path)?;
1645                            document_body_ops.push(op);
1646                        }
1647                        "fields" | "named" => {
1648                            if op.path.len() < 2 {
1649                                return Err(crate::RedDBError::Query(
1650                                    "patch path 'fields' requires a nested key".to_string(),
1651                                ));
1652                            }
1653                            if row_contract_timestamps {
1654                                let leaf = op.path.get(1).map(String::as_str);
1655                                if matches!(leaf, Some("created_at") | Some("updated_at")) {
1656                                    return Err(crate::RedDBError::Query(format!(
1657                                        "collection '{}' manages '{}' automatically — do not set it in UPDATE",
1658                                        collection,
1659                                        leaf.unwrap_or("")
1660                                    )));
1661                                }
1662                            }
1663                            op.path.remove(0);
1664                            field_ops.push(op);
1665                        }
1666                        "metadata" => {
1667                            if op.path.len() < 2 {
1668                                return Err(crate::RedDBError::Query(
1669                                    "patch path 'metadata' requires a nested key".to_string(),
1670                                ));
1671                            }
1672                            op.path.remove(0);
1673                            metadata_ops.push(op);
1674                        }
1675                        _ => {
1676                            return Err(crate::RedDBError::Query(format!(
1677                                "unsupported patch target '{root}' for table rows. Use fields/*, metadata/*, or weight"
1678                            )));
1679                        }
1680                    }
1681                }
1682
1683                if document_body_replace.is_some() || !document_body_ops.is_empty() {
1684                    context_index_dirty = true;
1685                    let mut body = match document_body_replace {
1686                        Some(body) => body,
1687                        None => document_body_from_named(
1688                            row.named.get_or_insert_with(Default::default),
1689                        )?,
1690                    };
1691                    apply_document_body_patch_operations(&mut body, &document_body_ops)?;
1692                    replace_document_row_body(
1693                        row,
1694                        body,
1695                        &collection,
1696                        binary_body,
1697                        &mut modified_columns,
1698                    )?;
1699                }
1700
1701                if is_document_collection && !has_patch_operations {
1702                    let mut body =
1703                        document_body_from_named(row.named.get_or_insert_with(Default::default))?;
1704                    let previous_body = body.clone();
1705                    apply_document_json_merge_patch(
1706                        &mut body,
1707                        document_merge_patch_payload(&payload),
1708                    )?;
1709                    if body != previous_body {
1710                        context_index_dirty = true;
1711                        replace_document_row_body(
1712                            row,
1713                            body,
1714                            &collection,
1715                            binary_body,
1716                            &mut modified_columns,
1717                        )?;
1718                    }
1719                }
1720
1721                if !field_ops.is_empty() {
1722                    context_index_dirty = true;
1723                    let named = row.named.get_or_insert_with(Default::default);
1724                    if is_document_collection {
1725                        // Document fields are projections of the canonical
1726                        // `body` JSON. Apply the assignment to the body so the
1727                        // stored document remains single-source.
1728                        let mut body = document_body_from_named(named)?;
1729                        apply_patch_operations_to_json(&mut body, &field_ops)
1730                            .map_err(crate::RedDBError::Query)?;
1731                        replace_document_row_body(
1732                            row,
1733                            body,
1734                            &collection,
1735                            binary_body,
1736                            &mut modified_columns,
1737                        )?;
1738                    } else {
1739                        for op in &field_ops {
1740                            if let Some(col) = op.path.first() {
1741                                modified_columns.push(col.clone());
1742                            }
1743                        }
1744                        apply_patch_operations_to_storage_map(named, &field_ops)?;
1745                    }
1746                }
1747
1748                if let Some(fields) = payload
1749                    .get("fields")
1750                    .and_then(crate::json::Value::as_object)
1751                {
1752                    if row_contract_timestamps {
1753                        for key in fields.keys() {
1754                            if key == "created_at" || key == "updated_at" {
1755                                return Err(crate::RedDBError::Query(format!(
1756                                    "collection '{}' manages '{}' automatically — do not set it in UPDATE",
1757                                    collection, key
1758                                )));
1759                            }
1760                        }
1761                    }
1762                    context_index_dirty = true;
1763                    let named = row.named.get_or_insert_with(Default::default);
1764                    if is_document_collection {
1765                        // Mirror the `field_ops` path above: keep the canonical
1766                        // `body` JSON as the single stored representation.
1767                        let mut body = document_body_from_named(named)?;
1768                        if let JsonValue::Object(map) = &mut body {
1769                            for (key, value) in fields {
1770                                map.insert(key.clone(), value.clone());
1771                            }
1772                        }
1773                        replace_document_row_body(
1774                            row,
1775                            body,
1776                            &collection,
1777                            binary_body,
1778                            &mut modified_columns,
1779                        )?;
1780                    } else {
1781                        for (key, value) in fields {
1782                            modified_columns.push(key.clone());
1783                            named.insert(key.clone(), json_to_storage_value(value)?);
1784                        }
1785                    }
1786                }
1787
1788                if !metadata_ops.is_empty() {
1789                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
1790                    let metadata = patch_metadata.get_or_insert_with(|| {
1791                        store.get_metadata(&collection, id).unwrap_or_default()
1792                    });
1793                    let mut metadata_json = metadata_to_json(metadata);
1794                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
1795                        .map_err(crate::RedDBError::Query)?;
1796                    *metadata = metadata_from_json(&metadata_json)?;
1797                    metadata_changed = true;
1798                }
1799
1800                if !modified_columns.is_empty() || row_contract_timestamps {
1801                    let contract = CollectionContractWriteEnforcer::new(&db, &collection);
1802                    let current_fields = if let Some(named) = row.named.take() {
1803                        named.into_iter().collect::<Vec<_>>()
1804                    } else if let Some(schema) = row.schema.as_ref() {
1805                        schema
1806                            .iter()
1807                            .cloned()
1808                            .zip(row.columns.iter().cloned())
1809                            .collect::<Vec<_>>()
1810                    } else {
1811                        Vec::new()
1812                    };
1813                    let normalized_fields = contract.normalize_update_fields(current_fields)?;
1814                    if row_contract_timestamps {
1815                        modified_columns.push("updated_at".to_string());
1816                        context_index_dirty = true;
1817                    }
1818                    if contract.requires_uniqueness_check(&modified_columns) {
1819                        contract.enforce_row_uniqueness(&normalized_fields, Some(id))?;
1820                    }
1821                    row.named = Some(normalized_fields.into_iter().collect());
1822                }
1823            }
1824            crate::storage::EntityData::Node(node) => {
1825                let mut field_ops = Vec::new();
1826                let mut metadata_ops = Vec::new();
1827                let mut node_type_ops = Vec::new();
1828
1829                for mut op in operations {
1830                    let Some(root) = op.path.first().map(String::as_str) else {
1831                        return Err(crate::RedDBError::Query(
1832                            "patch path cannot be empty".to_string(),
1833                        ));
1834                    };
1835
1836                    match root {
1837                        "fields" | "properties" => {
1838                            if op.path.len() < 2 {
1839                                return Err(crate::RedDBError::Query(
1840                                    "patch path 'fields' requires a nested key".to_string(),
1841                                ));
1842                            }
1843                            op.path.remove(0);
1844                            field_ops.push(op);
1845                        }
1846                        "metadata" => {
1847                            if op.path.len() < 2 {
1848                                return Err(crate::RedDBError::Query(
1849                                    "patch path 'metadata' requires a nested key".to_string(),
1850                                ));
1851                            }
1852                            op.path.remove(0);
1853                            metadata_ops.push(op);
1854                        }
1855                        "node_type" => {
1856                            if op.path.len() != 1 {
1857                                return Err(crate::RedDBError::Query(
1858                                    "patch path 'node_type' does not allow nested keys".to_string(),
1859                                ));
1860                            }
1861                            op.path.clear();
1862                            node_type_ops.push(op);
1863                        }
1864                        _ => {
1865                            return Err(crate::RedDBError::Query(format!(
1866                                "unsupported patch target '{root}' for graph nodes. Use fields/*, properties/*, node_type, or metadata/*"
1867                            )));
1868                        }
1869                    }
1870                }
1871
1872                for op in node_type_ops {
1873                    context_index_dirty = true;
1874                    let value = op.value.ok_or_else(|| {
1875                        crate::RedDBError::Query("node_type operations require a value".to_string())
1876                    })?;
1877
1878                    match op.op {
1879                        PatchEntityOperationType::Unset => {
1880                            return Err(crate::RedDBError::Query(
1881                                "node_type cannot be unset through patch operations".to_string(),
1882                            ));
1883                        }
1884                        PatchEntityOperationType::Set | PatchEntityOperationType::Replace => {
1885                            let Some(node_type) = value.as_str() else {
1886                                return Err(crate::RedDBError::Query(
1887                                    "node_type operation requires a text value".to_string(),
1888                                ));
1889                            };
1890                            graph_node_type = Some(node_type.to_string());
1891                            modified_columns.push("node_type".to_string());
1892                        }
1893                    }
1894                }
1895
1896                if !field_ops.is_empty() {
1897                    context_index_dirty = true;
1898                    apply_patch_operations_to_storage_map(&mut node.properties, &field_ops)?;
1899                }
1900
1901                if let Some(fields) = payload
1902                    .get("fields")
1903                    .and_then(crate::json::Value::as_object)
1904                {
1905                    context_index_dirty = true;
1906                    for (key, value) in fields {
1907                        node.properties
1908                            .insert(key.clone(), json_to_storage_value(value)?);
1909                    }
1910                }
1911
1912                if !metadata_ops.is_empty() {
1913                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
1914                    let metadata = patch_metadata.get_or_insert_with(|| {
1915                        store.get_metadata(&collection, id).unwrap_or_default()
1916                    });
1917                    let mut metadata_json = metadata_to_json(metadata);
1918                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
1919                        .map_err(crate::RedDBError::Query)?;
1920                    *metadata = metadata_from_json(&metadata_json)?;
1921                    metadata_changed = true;
1922                }
1923            }
1924            crate::storage::EntityData::Edge(edge) => {
1925                let mut field_ops = Vec::new();
1926                let mut metadata_ops = Vec::new();
1927                let mut weight_ops = Vec::new();
1928
1929                for mut op in operations {
1930                    let Some(root) = op.path.first().map(String::as_str) else {
1931                        return Err(crate::RedDBError::Query(
1932                            "patch path cannot be empty".to_string(),
1933                        ));
1934                    };
1935
1936                    match root {
1937                        "fields" | "properties" => {
1938                            if op.path.len() < 2 {
1939                                return Err(crate::RedDBError::Query(
1940                                    "patch path 'fields' requires a nested key".to_string(),
1941                                ));
1942                            }
1943                            op.path.remove(0);
1944                            field_ops.push(op);
1945                        }
1946                        "weight" => {
1947                            if op.path.len() != 1 {
1948                                return Err(crate::RedDBError::Query(
1949                                    "patch path 'weight' does not allow nested keys".to_string(),
1950                                ));
1951                            }
1952                            op.path.clear();
1953                            weight_ops.push(op);
1954                        }
1955                        "metadata" => {
1956                            if op.path.len() < 2 {
1957                                return Err(crate::RedDBError::Query(
1958                                    "patch path 'metadata' requires a nested key".to_string(),
1959                                ));
1960                            }
1961                            op.path.remove(0);
1962                            metadata_ops.push(op);
1963                        }
1964                        _ => {
1965                            return Err(crate::RedDBError::Query(format!(
1966                                "unsupported patch target '{root}' for graph edges. Use fields/*, weight, metadata/*"
1967                            )));
1968                        }
1969                    }
1970                }
1971
1972                if !field_ops.is_empty() {
1973                    context_index_dirty = true;
1974                    apply_patch_operations_to_storage_map(&mut edge.properties, &field_ops)?;
1975                }
1976
1977                for op in weight_ops {
1978                    context_index_dirty = true;
1979                    let value = op.value.ok_or_else(|| {
1980                        crate::RedDBError::Query("weight operations require a value".to_string())
1981                    })?;
1982
1983                    match op.op {
1984                        PatchEntityOperationType::Unset => {
1985                            return Err(crate::RedDBError::Query(
1986                                "weight cannot be unset through patch operations".to_string(),
1987                            ));
1988                        }
1989                        PatchEntityOperationType::Set | PatchEntityOperationType::Replace => {
1990                            let Some(weight) = value.as_f64() else {
1991                                return Err(crate::RedDBError::Query(
1992                                    "weight operation requires a numeric value".to_string(),
1993                                ));
1994                            };
1995                            graph_edge_weight = Some(weight as f32);
1996                            edge.weight = weight as f32;
1997                            modified_columns.push("weight".to_string());
1998                        }
1999                    }
2000                }
2001
2002                if let Some(fields) = payload
2003                    .get("fields")
2004                    .and_then(crate::json::Value::as_object)
2005                {
2006                    context_index_dirty = true;
2007                    for (key, value) in fields {
2008                        edge.properties
2009                            .insert(key.clone(), json_to_storage_value(value)?);
2010                    }
2011                }
2012
2013                if !metadata_ops.is_empty() {
2014                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
2015                    let metadata = patch_metadata.get_or_insert_with(|| {
2016                        store.get_metadata(&collection, id).unwrap_or_default()
2017                    });
2018                    let mut metadata_json = metadata_to_json(metadata);
2019                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
2020                        .map_err(crate::RedDBError::Query)?;
2021                    *metadata = metadata_from_json(&metadata_json)?;
2022                    metadata_changed = true;
2023                }
2024            }
2025            crate::storage::EntityData::Vector(vector) => {
2026                let mut field_ops = Vec::new();
2027                let mut metadata_ops = Vec::new();
2028
2029                for mut op in operations {
2030                    let Some(root) = op.path.first().map(String::as_str) else {
2031                        return Err(crate::RedDBError::Query(
2032                            "patch path cannot be empty".to_string(),
2033                        ));
2034                    };
2035
2036                    match root {
2037                        "fields" => {
2038                            if op.path.len() < 2 {
2039                                return Err(crate::RedDBError::Query(
2040                                    "patch path 'fields' requires a nested key".to_string(),
2041                                ));
2042                            }
2043                            op.path.remove(0);
2044                            let Some(target) = op.path.first().map(String::as_str) else {
2045                                return Err(crate::RedDBError::Query(
2046                                    "patch path requires a target under fields".to_string(),
2047                                ));
2048                            };
2049                            if !matches!(target, "dense" | "content" | "sparse") {
2050                                return Err(crate::RedDBError::Query(format!(
2051                                    "unsupported vector patch target '{target}'"
2052                                )));
2053                            }
2054                            field_ops.push(op);
2055                        }
2056                        "metadata" => {
2057                            if op.path.len() < 2 {
2058                                return Err(crate::RedDBError::Query(
2059                                    "patch path 'metadata' requires a nested key".to_string(),
2060                                ));
2061                            }
2062                            op.path.remove(0);
2063                            metadata_ops.push(op);
2064                        }
2065                        _ => {
2066                            return Err(crate::RedDBError::Query(format!(
2067                                "unsupported patch target '{root}' for vectors. Use fields/* or metadata/*"
2068                            )));
2069                        }
2070                    }
2071                }
2072
2073                if !field_ops.is_empty() {
2074                    context_index_dirty = true;
2075                    apply_patch_operations_to_vector_fields(vector, &field_ops)?;
2076                }
2077
2078                if let Some(fields) = payload
2079                    .get("fields")
2080                    .and_then(crate::json::Value::as_object)
2081                {
2082                    context_index_dirty = true;
2083                    if let Some(content) =
2084                        fields.get("content").and_then(crate::json::Value::as_str)
2085                    {
2086                        vector.content = Some(content.to_string());
2087                    }
2088                    if let Some(dense) = fields.get("dense") {
2089                        vector.dense = dense
2090                            .as_array()
2091                            .ok_or_else(|| {
2092                                crate::RedDBError::Query(
2093                                    "field 'dense' must be an array".to_string(),
2094                                )
2095                            })?
2096                            .iter()
2097                            .map(|value| {
2098                                value.as_f64().map(|value| value as f32).ok_or_else(|| {
2099                                    crate::RedDBError::Query(
2100                                        "field 'dense' must contain only numbers".to_string(),
2101                                    )
2102                                })
2103                            })
2104                            .collect::<Result<Vec<_>, _>>()?;
2105                    }
2106                }
2107
2108                if !metadata_ops.is_empty() {
2109                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
2110                    let metadata = patch_metadata.get_or_insert_with(|| {
2111                        store.get_metadata(&collection, id).unwrap_or_default()
2112                    });
2113                    let mut metadata_json = metadata_to_json(metadata);
2114                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
2115                        .map_err(crate::RedDBError::Query)?;
2116                    *metadata = metadata_from_json(&metadata_json)?;
2117                    metadata_changed = true;
2118                }
2119            }
2120            crate::storage::EntityData::TimeSeries(_)
2121            | crate::storage::EntityData::QueueMessage(_) => {
2122                return Err(crate::RedDBError::Query(
2123                    "patch operations are not supported for TimeSeries or QueueMessage entities"
2124                        .to_string(),
2125                ));
2126            }
2127        }
2128
2129        if let Some(node_type) = graph_node_type {
2130            if let crate::storage::EntityKind::GraphNode(node) = &mut entity.kind {
2131                node.node_type = node_type;
2132            }
2133        }
2134        if let Some(weight) = graph_edge_weight {
2135            if let crate::storage::EntityKind::GraphEdge(edge) = &mut entity.kind {
2136                edge.weight = (weight * 1000.0) as u32;
2137            }
2138        }
2139
2140        if let Some(metadata) = payload
2141            .get("metadata")
2142            .and_then(crate::json::Value::as_object)
2143        {
2144            let patch_metadata = patch_metadata
2145                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default());
2146            for (key, value) in metadata {
2147                ensure_non_tree_reserved_metadata_key(key)?;
2148                patch_metadata.set(key.clone(), json_to_metadata_value(value)?);
2149            }
2150            metadata_changed = true;
2151        }
2152
2153        for (key, value) in parse_top_level_ttl_metadata_entries(&payload)? {
2154            let patch_metadata = patch_metadata
2155                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default());
2156            if matches!(value, crate::storage::unified::MetadataValue::Null) {
2157                patch_metadata.remove(&key);
2158            } else {
2159                patch_metadata.set(key, value);
2160            }
2161            metadata_changed = true;
2162        }
2163
2164        entity.updated_at = std::time::SystemTime::now()
2165            .duration_since(std::time::UNIX_EPOCH)
2166            .unwrap_or_default()
2167            .as_secs();
2168
2169        // Versioned PATCH: re-stamp the merged post-image as a fresh
2170        // physical version keyed on the same logical_id, and close the
2171        // prior version's xmax at the same xid. `install_versioned_*`
2172        // in persist installs the new row and tombstones the old one;
2173        // `record_pending_versioned_update` arms first-committer-wins.
2174        if let Some(xid) = versioned_update_xid {
2175            let logical_id = entity.logical_id();
2176            entity.id = store.next_entity_id();
2177            entity.set_logical_id(logical_id);
2178            entity.set_xmin(xid);
2179            entity.set_xmax(0);
2180            if let Some(old) = replaced_entity.as_mut() {
2181                old.set_xmax(xid);
2182            }
2183        }
2184
2185        modified_columns = dedupe_modified_columns(modified_columns);
2186
2187        Ok(AppliedEntityMutation {
2188            id: entity.id,
2189            collection,
2190            entity,
2191            metadata: patch_metadata,
2192            modified_columns,
2193            persist_metadata: metadata_changed,
2194            context_index_dirty,
2195            replaced_entity,
2196            replaced_entity_previous_xmax: previous_xmax,
2197            pre_mutation_fields,
2198        })
2199    }
2200
2201    pub(crate) fn apply_loaded_sql_update_row_core(
2202        &self,
2203        collection: String,
2204        mut entity: crate::storage::UnifiedEntity,
2205        static_field_assignments: &[(String, Value)],
2206        dynamic_field_assignments: Vec<(String, Value)>,
2207        static_metadata_assignments: &[(String, MetadataValue)],
2208        dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
2209        row_contract_plan: Option<&RowUpdateContractPlan>,
2210        row_modified_columns_template: &[String],
2211        row_touches_unique_columns: bool,
2212    ) -> RedDBResult<AppliedEntityMutation> {
2213        let id = entity.id;
2214        let previous_xmax = entity.xmax;
2215        let db = self.db();
2216        let store = db.store();
2217        let Some(_) = store.get_collection(&collection) else {
2218            return Err(crate::RedDBError::NotFound(format!(
2219                "collection not found: {collection}"
2220            )));
2221        };
2222
2223        let versioned_update_xid = match self.current_xid() {
2224            Some(xid) => Some(xid),
2225            None => {
2226                let snapshot_manager = self.snapshot_manager();
2227                let xid = snapshot_manager.begin();
2228                snapshot_manager.commit(xid);
2229                Some(xid)
2230            }
2231        };
2232        let mut replaced_entity = versioned_update_xid.map(|xid| {
2233            let mut old = entity.clone();
2234            old.set_xmax(xid);
2235            old
2236        });
2237
2238        let mut patch_metadata: Option<crate::storage::unified::Metadata> = None;
2239        let row_contract_timestamps = row_contract_plan
2240            .map(|plan| plan.timestamps_enabled)
2241            .unwrap_or(false);
2242        let mut metadata_changed = false;
2243        let mut modified_columns = row_modified_columns_template.to_vec();
2244        let mut context_index_dirty = !modified_columns.is_empty();
2245
2246        // Snapshot OLD field values BEFORE applying the assignments —
2247        // the secondary-index maintenance hook needs both before/after to
2248        // delete-then-insert under changed indexed columns.
2249        let pre_mutation_fields = entity_row_fields_snapshot(&entity);
2250
2251        let crate::storage::EntityData::Row(row) = &mut entity.data else {
2252            return Err(crate::RedDBError::Query(
2253                "SQL row update fast path requires a row entity".to_string(),
2254            ));
2255        };
2256
2257        let _ = row_contract_plan;
2258
2259        // Documents store the full JSON under the `body` column. Legacy rows
2260        // also promote top-level keys for filtering, while binary body mode
2261        // keeps `body` as the single source of truth. Reflect non-body SET
2262        // assignments into `body` as patch operations so dotted paths update
2263        // nested JSON instead of becoming literal top-level keys.
2264        let is_document_collection = db
2265            .collection_contract(&collection)
2266            .map(|contract| contract.declared_model == crate::catalog::CollectionModel::Document)
2267            .unwrap_or(false);
2268        let kv_key = if row.get_field("value").is_some() || row.columns.len() >= 2 {
2269            row.get_field("key")
2270                .cloned()
2271                .or_else(|| row.columns.first().cloned())
2272        } else {
2273            None
2274        };
2275        if is_document_collection {
2276            // #1366: when a SET assigns to `body`, treat it as a FULL REPLACE of
2277            // the stored document — drop other column assignments, the new body
2278            // is the single source of truth.
2279            let body_assignment = static_field_assignments
2280                .iter()
2281                .chain(dynamic_field_assignments.iter())
2282                .rev()
2283                .find(|(column, _)| column == "body")
2284                .cloned();
2285
2286            match body_assignment {
2287                Some((_, value)) => {
2288                    let body = document_body_from_assignment(&value)?;
2289                    replace_document_row_body(
2290                        row,
2291                        body,
2292                        &collection,
2293                        self.binary_document_body_enabled(),
2294                        &mut modified_columns,
2295                    )?;
2296                    context_index_dirty = true;
2297                }
2298                None => {
2299                    // #1365: no `body` assignment — fall back to nested-path
2300                    // partial update via document_body_set_operation.
2301                    let mut static_row_assignments = Vec::new();
2302                    let mut dynamic_row_assignments = Vec::new();
2303                    let mut document_body_ops = Vec::new();
2304                    for (column, value) in static_field_assignments.iter().cloned() {
2305                        if column == "body" {
2306                            static_row_assignments.push((column, value));
2307                        } else {
2308                            document_body_ops.push(document_body_set_operation(&column, value));
2309                        }
2310                    }
2311                    for (column, value) in dynamic_field_assignments {
2312                        if column == "body" {
2313                            dynamic_row_assignments.push((column, value));
2314                        } else {
2315                            document_body_ops.push(document_body_set_operation(&column, value));
2316                        }
2317                    }
2318                    apply_row_field_assignments_raw(row, static_row_assignments);
2319                    apply_row_field_assignments_raw(row, dynamic_row_assignments);
2320                    if !document_body_ops.is_empty() {
2321                        let mut body = document_body_from_named(
2322                            row.named.get_or_insert_with(Default::default),
2323                        )?;
2324                        apply_document_body_patch_operations(&mut body, &document_body_ops)?;
2325                        replace_document_row_body(
2326                            row,
2327                            body,
2328                            &collection,
2329                            self.binary_document_body_enabled(),
2330                            &mut modified_columns,
2331                        )?;
2332                        context_index_dirty = true;
2333                    }
2334                }
2335            }
2336        } else {
2337            // Non-document collection: every assignment is a regular row column.
2338            let mut all_assignments: Vec<(String, Value)> = static_field_assignments.to_vec();
2339            all_assignments.extend(dynamic_field_assignments);
2340            apply_row_field_assignments_raw(row, all_assignments);
2341            if let Some(key) = kv_key {
2342                set_row_field(row, "key", key);
2343            }
2344        }
2345
2346        for (key, value) in static_metadata_assignments
2347            .iter()
2348            .cloned()
2349            .chain(dynamic_metadata_assignments)
2350        {
2351            ensure_non_tree_reserved_metadata_key(&key)?;
2352            patch_metadata
2353                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default())
2354                .set(key, value);
2355            metadata_changed = true;
2356        }
2357
2358        if !modified_columns.is_empty() || row_contract_timestamps {
2359            let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2360            if row_contract_timestamps {
2361                context_index_dirty = true;
2362                set_row_field(
2363                    row,
2364                    "updated_at",
2365                    Value::UnsignedInteger(current_unix_ms_u64()),
2366                );
2367                modified_columns.push("updated_at".to_string());
2368            }
2369            if row_touches_unique_columns {
2370                let current_fields = collect_row_fields(row);
2371                contract.enforce_row_uniqueness(&current_fields, Some(id))?;
2372            }
2373        }
2374
2375        entity.updated_at = std::time::SystemTime::now()
2376            .duration_since(std::time::UNIX_EPOCH)
2377            .unwrap_or_default()
2378            .as_secs();
2379
2380        if let Some(xid) = versioned_update_xid {
2381            let logical_id = entity.logical_id();
2382            entity.id = store.next_entity_id();
2383            entity.set_logical_id(logical_id);
2384            entity.set_xmin(xid);
2385            entity.set_xmax(0);
2386            if let Some(old) = replaced_entity.as_mut() {
2387                old.set_xmax(xid);
2388            }
2389        }
2390
2391        modified_columns = dedupe_modified_columns(modified_columns);
2392
2393        Ok(AppliedEntityMutation {
2394            id: entity.id,
2395            collection,
2396            entity,
2397            metadata: patch_metadata,
2398            modified_columns,
2399            persist_metadata: metadata_changed,
2400            context_index_dirty,
2401            replaced_entity,
2402            replaced_entity_previous_xmax: previous_xmax,
2403            pre_mutation_fields,
2404        })
2405    }
2406
2407    pub(crate) fn persist_applied_entity_mutations(
2408        &self,
2409        applied: &[AppliedEntityMutation],
2410    ) -> RedDBResult<()> {
2411        if applied.is_empty() {
2412            return Ok(());
2413        }
2414
2415        let store = self.db().store();
2416        let collection = &applied[0].collection;
2417        let Some(manager) = store.get_collection(collection) else {
2418            return Err(crate::RedDBError::NotFound(format!(
2419                "collection not found: {collection}"
2420            )));
2421        };
2422
2423        let mut ordinary = Vec::with_capacity(applied.len());
2424        for item in applied {
2425            if let Some(old_version) = item.replaced_entity.as_ref() {
2426                store
2427                    .install_versioned_table_row_update(
2428                        collection,
2429                        old_version.clone(),
2430                        item.entity.clone(),
2431                        if item.persist_metadata {
2432                            item.metadata.as_ref()
2433                        } else {
2434                            None
2435                        },
2436                    )
2437                    .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
2438                if self.current_xid().is_some() {
2439                    self.record_pending_versioned_update(
2440                        crate::runtime::impl_core::current_connection_id(),
2441                        collection,
2442                        old_version.id,
2443                        item.entity.id,
2444                        old_version.xmax,
2445                        item.replaced_entity_previous_xmax,
2446                    );
2447                }
2448            } else {
2449                ordinary.push(item);
2450            }
2451        }
2452        if ordinary.is_empty() {
2453            return Ok(());
2454        }
2455
2456        manager
2457            .update_hot_batch_with_metadata(ordinary.iter().map(|item| {
2458                (
2459                    &item.entity,
2460                    item.modified_columns.as_slice(),
2461                    if item.persist_metadata {
2462                        item.metadata.as_ref()
2463                    } else {
2464                        None
2465                    },
2466                )
2467            }))
2468            .map_err(|err| crate::RedDBError::Query(err.to_string()))?;
2469
2470        // PG-HOT-like fast path: segment in-place is done; when no
2471        // mutation touches a secondary-indexed column AND no metadata
2472        // payload needs to be folded into a B-tree record, skip the
2473        // in-line B-tree upsert. The WAL still records the update
2474        // (durability preserved; recovery replay rebuilds the B-tree),
2475        // and `manager.get()` prefers the live segment over the
2476        // B-tree for reads — so the short-circuit is invisible to
2477        // callers. See `persist_entities_to_pager_wal_only`.
2478        let indexed_cols = self
2479            .index_store_ref()
2480            .indexed_columns_set(collection.as_str());
2481        let all_hot = !indexed_cols.is_empty()
2482            && ordinary.iter().all(|item| {
2483                !item.persist_metadata
2484                    && !item
2485                        .modified_columns
2486                        .iter()
2487                        .any(|c| indexed_cols.contains(c))
2488            })
2489            || indexed_cols.is_empty() && ordinary.iter().all(|item| !item.persist_metadata);
2490
2491        // Pass `&[&UnifiedEntity]` — no per-entity clone. The SQL UPDATE
2492        // inner loop hands us `applied` which already owns the post-image
2493        // entity; all we need for the persist path is a read borrow.
2494        let entity_refs: Vec<&crate::storage::UnifiedEntity> =
2495            ordinary.iter().map(|item| &item.entity).collect();
2496        let persist_fn = if all_hot {
2497            crate::storage::unified::UnifiedStore::persist_entity_refs_to_pager_wal_only
2498        } else {
2499            crate::storage::unified::UnifiedStore::persist_entity_refs_to_pager
2500        };
2501        persist_fn(store.as_ref(), collection, &entity_refs)
2502            .map_err(|err| crate::RedDBError::Internal(err.to_string()))
2503    }
2504
2505    pub(crate) fn flush_applied_entity_mutation(
2506        &self,
2507        applied: &AppliedEntityMutation,
2508    ) -> RedDBResult<()> {
2509        let store = self.db().store();
2510        if applied.context_index_dirty {
2511            store
2512                .context_index()
2513                .index_entity(&applied.collection, &applied.entity);
2514        }
2515        // Secondary-index maintenance for SQL UPDATE / JSON-Patch flows.
2516        // Skip when pre_mutation_fields is empty (entity wasn't a row, or
2517        // didn't carry recoverable column names) — there's nothing to
2518        // delete-then-insert in that case.
2519        //
2520        // Also build the CDC damage vector here so downstream consumers
2521        // see which columns changed without re-diffing.
2522        let mut changed_columns: Option<Vec<String>> = None;
2523        if !applied.pre_mutation_fields.is_empty() {
2524            let post = entity_row_fields_snapshot(&applied.entity);
2525            if !post.is_empty() {
2526                let damage = crate::application::entity::row_damage_vector(
2527                    &applied.pre_mutation_fields,
2528                    &post,
2529                );
2530                if !damage.is_empty() {
2531                    changed_columns = Some(
2532                        damage
2533                            .touched_columns()
2534                            .into_iter()
2535                            .map(str::to_string)
2536                            .collect(),
2537                    );
2538                }
2539
2540                // HOT-like fast path (P3.T2/T3): when no modified
2541                // column is covered by a secondary index, skip the
2542                // `index_entity_update` call entirely. The function
2543                // would short-circuit internally, but the call still
2544                // reads the registry lock + walks the damage vector
2545                // — avoiding it saves a few microseconds per UPDATE.
2546                // Page-local replace + t_ctid chain support (true
2547                // HOT) lives in a follow-up storage spec.
2548                // Use the parent-expanded set so that dot-path indexes
2549                // (e.g. "body.service.tier") are triggered when the root
2550                // field ("body") appears in modified_columns.
2551                let indexed_cols: std::collections::HashSet<String> = self
2552                    .index_store_ref()
2553                    .indexed_columns_set_with_parents(applied.collection.as_str());
2554                // Single-source documents keep promoted index columns in the
2555                // body — surface them so the index sees the value move (a no-op
2556                // for ordinary stored columns).
2557                let pre_index_fields = self
2558                    .index_store_ref()
2559                    .augment_body_derived_index_fields(&applied.pre_mutation_fields, &indexed_cols);
2560                let post_index_fields = self
2561                    .index_store_ref()
2562                    .augment_body_derived_index_fields(&post, &indexed_cols);
2563                let modified_cols: std::collections::HashSet<String> =
2564                    crate::application::entity::row_damage_vector(
2565                        &pre_index_fields,
2566                        &post_index_fields,
2567                    )
2568                    .touched_columns()
2569                    .into_iter()
2570                    .map(str::to_string)
2571                    .collect();
2572                if let Some(old_version) = applied.replaced_entity.as_ref() {
2573                    let old_index_fields: Vec<(String, Value)> = pre_index_fields
2574                        .iter()
2575                        .filter(|(col, _)| indexed_cols.contains(col))
2576                        .cloned()
2577                        .collect();
2578                    let new_index_fields: Vec<(String, Value)> = post_index_fields
2579                        .iter()
2580                        .filter(|(col, _)| indexed_cols.contains(col))
2581                        .cloned()
2582                        .collect();
2583                    if !old_index_fields.is_empty() {
2584                        self.index_store_ref()
2585                            .index_entity_delete(
2586                                &applied.collection,
2587                                old_version.id,
2588                                &old_index_fields,
2589                            )
2590                            .map_err(crate::RedDBError::Internal)?;
2591                    }
2592                    if !new_index_fields.is_empty() {
2593                        self.index_store_ref()
2594                            .index_entity_insert(
2595                                &applied.collection,
2596                                applied.entity.id,
2597                                &new_index_fields,
2598                            )
2599                            .map_err(crate::RedDBError::Internal)?;
2600                    }
2601                } else {
2602                    let decision = crate::storage::engine::hot_update::decide(
2603                        &crate::storage::engine::hot_update::HotUpdateInputs {
2604                            collection: applied.collection.as_str(),
2605                            indexed_columns: &indexed_cols,
2606                            modified_columns: &modified_cols,
2607                            // The storage layer currently handles fit via
2608                            // the segment abstraction; we bypass the
2609                            // page-size check here.
2610                            new_tuple_size: 0,
2611                            page_free_space: usize::MAX,
2612                        },
2613                    );
2614                    if !decision.can_hot {
2615                        self.index_store_ref()
2616                            .index_entity_update(
2617                                &applied.collection,
2618                                applied.id,
2619                                &pre_index_fields,
2620                                &post_index_fields,
2621                            )
2622                            .map_err(crate::RedDBError::Internal)?;
2623                    } else {
2624                        // F-04: `applied.collection` is tenant-supplied;
2625                        // strip CR/LF/control bytes via the LogField
2626                        // escaper (ADR 0010).
2627                        tracing::debug!(
2628                            collection = %reddb_wire::audit_safe_log_field(&applied.collection),
2629                            "hot_update fast-path: skipped index_entity_update"
2630                        );
2631                    }
2632                }
2633            }
2634        }
2635        self.cdc_emit_prebuilt_with_columns(
2636            crate::replication::cdc::ChangeOperation::Update,
2637            &applied.collection,
2638            &applied.entity,
2639            "entity",
2640            applied.metadata.as_ref(),
2641            true,
2642            changed_columns,
2643        );
2644        Ok(())
2645    }
2646
2647    pub(crate) fn apply_loaded_patch_entity(
2648        &self,
2649        collection: String,
2650        entity: crate::storage::UnifiedEntity,
2651        payload: JsonValue,
2652        operations: Vec<PatchEntityOperation>,
2653    ) -> RedDBResult<CreateEntityOutput> {
2654        let applied =
2655            self.apply_loaded_patch_entity_core(collection, entity, payload, operations)?;
2656        self.persist_applied_entity_mutations(std::slice::from_ref(&applied))?;
2657        self.flush_applied_entity_mutation(&applied)?;
2658        Ok(CreateEntityOutput {
2659            id: applied.id,
2660            entity: Some(public_document_entity(applied.entity)),
2661        })
2662    }
2663}
2664
2665/// Shape the canonical binary-body document into the public response entity.
2666/// The stored `body` is the single source of truth (RDOC binary container),
2667/// but callers read the returned entity two ways, so the response satisfies
2668/// both: top-level body fields are surfaced into `named` (mirroring the GET
2669/// presentation derive in `named_fields_json`), and `body` itself is decoded
2670/// back to plain JSON. Derivation runs against the binary container first,
2671/// then `body` is replaced with its JSON form. This only reshapes the
2672/// returned copy — persistence already happened against the binary body.
2673fn public_document_entity(
2674    mut entity: crate::storage::UnifiedEntity,
2675) -> crate::storage::UnifiedEntity {
2676    let crate::storage::EntityData::Row(row) = &mut entity.data else {
2677        return entity;
2678    };
2679    let Some(named) = row.named.as_mut() else {
2680        return entity;
2681    };
2682    let Some(Value::Json(bytes)) = named.get("body") else {
2683        return entity;
2684    };
2685    let bytes = bytes.clone();
2686    if let Some(fields) = crate::document_body::body_fields(&bytes) {
2687        for (name, value) in fields {
2688            named.entry(name).or_insert(value);
2689        }
2690    }
2691    if let Some(body_json) = crate::document_body::decode_container_to_json(&bytes) {
2692        if let Ok(json_bytes) = crate::json::to_vec(&body_json) {
2693            named.insert("body".to_string(), Value::Json(json_bytes));
2694        }
2695    }
2696    entity
2697}
2698
2699fn ensure_non_tree_reserved_metadata_patch_paths(
2700    operations: &[PatchEntityOperation],
2701) -> RedDBResult<()> {
2702    for operation in operations {
2703        let Some(key) = operation.path.first().map(String::as_str) else {
2704            continue;
2705        };
2706        ensure_non_tree_reserved_metadata_key(key)?;
2707    }
2708    Ok(())
2709}
2710
2711fn ensure_non_tree_reserved_metadata_key(key: &str) -> RedDBResult<()> {
2712    if key.starts_with(TREE_METADATA_PREFIX) {
2713        return Err(crate::RedDBError::Query(format!(
2714            "metadata key '{}' is reserved for managed trees",
2715            key
2716        )));
2717    }
2718    Ok(())
2719}
2720
2721fn ensure_non_tree_reserved_metadata_entries(
2722    metadata: &[(String, MetadataValue)],
2723) -> RedDBResult<()> {
2724    for (key, _) in metadata {
2725        ensure_non_tree_reserved_metadata_key(key)?;
2726    }
2727    Ok(())
2728}
2729
2730fn ensure_non_tree_structural_edge_label(label: &str) -> RedDBResult<()> {
2731    if label.eq_ignore_ascii_case(TREE_CHILD_EDGE_LABEL) {
2732        return Err(crate::RedDBError::Query(format!(
2733            "edge label '{}' is reserved for managed trees",
2734            TREE_CHILD_EDGE_LABEL
2735        )));
2736    }
2737    Ok(())
2738}
2739
2740impl RedDBRuntime {
2741    pub(crate) fn create_node_unchecked(
2742        &self,
2743        input: CreateNodeInput,
2744    ) -> RedDBResult<CreateEntityOutput> {
2745        let db = self.db();
2746        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
2747        contract.ensure_model(crate::catalog::CollectionModel::Graph)?;
2748        let mut metadata = input.metadata;
2749        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
2750        let mut builder = db.node(&input.collection, &input.label);
2751
2752        if let Some(node_type) = input.node_type {
2753            builder = builder.node_type(node_type);
2754        }
2755
2756        for (key, value) in input.properties {
2757            builder = builder.property(key, value);
2758        }
2759
2760        for (key, value) in metadata {
2761            builder = builder.metadata(key, value);
2762        }
2763
2764        for embedding in input.embeddings {
2765            if let Some(model) = embedding.model {
2766                builder = builder.embedding_with_model(embedding.name, embedding.vector, model);
2767            } else {
2768                builder = builder.embedding(embedding.name, embedding.vector);
2769            }
2770        }
2771
2772        for link in input.table_links {
2773            builder = builder.link_to_table(link.key, link.table);
2774        }
2775
2776        for link in input.node_links {
2777            builder = builder.link_to_weighted(link.target, link.edge_label, link.weight);
2778        }
2779
2780        let id = builder.save()?;
2781        // Phase 1.1 MVCC universal: stamp xmin so concurrent snapshots
2782        // don't see this node until the transaction commits.
2783        self.stamp_xmin_if_in_txn(&input.collection, id);
2784        refresh_context_index(&db, &input.collection, id)?;
2785        self.cdc_emit(
2786            crate::replication::cdc::ChangeOperation::Insert,
2787            &input.collection,
2788            id.raw(),
2789            "graph_node",
2790        );
2791        Ok(CreateEntityOutput {
2792            id,
2793            entity: db.store().get(&input.collection, id),
2794        })
2795    }
2796
2797    pub(crate) fn create_edge_unchecked(
2798        &self,
2799        input: CreateEdgeInput,
2800    ) -> RedDBResult<CreateEntityOutput> {
2801        let db = self.db();
2802        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
2803        contract.ensure_model(crate::catalog::CollectionModel::Graph)?;
2804        let mut metadata = input.metadata;
2805        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
2806        let mut builder = db
2807            .edge(&input.collection, &input.label)
2808            .from(input.from)
2809            .to(input.to);
2810
2811        if let Some(weight) = input.weight {
2812            builder = builder.weight(weight);
2813        }
2814
2815        for (key, value) in input.properties {
2816            builder = builder.property(key, value);
2817        }
2818
2819        for (key, value) in metadata {
2820            builder = builder.metadata(key, value);
2821        }
2822
2823        let id = builder.save()?;
2824        // Phase 1.1 MVCC universal: stamp xmin on the edge so other
2825        // sessions don't follow it until COMMIT.
2826        self.stamp_xmin_if_in_txn(&input.collection, id);
2827        refresh_context_index(&db, &input.collection, id)?;
2828        self.cdc_emit(
2829            crate::replication::cdc::ChangeOperation::Insert,
2830            &input.collection,
2831            id.raw(),
2832            "graph_edge",
2833        );
2834        Ok(CreateEntityOutput {
2835            id,
2836            entity: db.store().get(&input.collection, id),
2837        })
2838    }
2839}
2840
2841fn create_rows_batch_prevalidated_columnar_with_outputs(
2842    runtime: &RedDBRuntime,
2843    collection: String,
2844    column_names: std::sync::Arc<Vec<String>>,
2845    rows: Vec<Vec<crate::storage::schema::Value>>,
2846) -> RedDBResult<Vec<CreateEntityOutput>> {
2847    use crate::storage::{
2848        unified::{EntityData, EntityKind, RowData},
2849        EntityId, UnifiedEntity,
2850    };
2851    use std::sync::Arc;
2852
2853    if rows.is_empty() {
2854        return Ok(Vec::new());
2855    }
2856    runtime.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2857    runtime.check_batch_size(rows.len())?;
2858    runtime.check_db_size()?;
2859
2860    let db = runtime.db();
2861    let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2862    contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2863
2864    let store = db.store();
2865    let table_arc: Arc<str> = Arc::from(collection.as_str());
2866
2867    let indexed_cols = runtime
2868        .index_store_ref()
2869        .indexed_columns_set(collection.as_str());
2870    let has_secondary_indexes = !indexed_cols.is_empty();
2871    let mut field_snapshots: Vec<Vec<(String, crate::storage::schema::Value)>> =
2872        if has_secondary_indexes {
2873            Vec::with_capacity(rows.len())
2874        } else {
2875            Vec::new()
2876        };
2877
2878    let entities: Vec<UnifiedEntity> = rows
2879        .into_iter()
2880        .map(|values| {
2881            if has_secondary_indexes {
2882                let mut snap: Vec<(String, crate::storage::schema::Value)> =
2883                    Vec::with_capacity(indexed_cols.len());
2884                for (name, val) in column_names.iter().zip(values.iter()) {
2885                    if indexed_cols.contains(name) {
2886                        snap.push((name.clone(), val.clone()));
2887                    }
2888                }
2889                field_snapshots.push(snap);
2890            }
2891            let mut row = RowData::new(values);
2892            row.schema = Some(Arc::clone(&column_names));
2893            UnifiedEntity::new(
2894                EntityId::new(0),
2895                EntityKind::TableRow {
2896                    table: Arc::clone(&table_arc),
2897                    row_id: 0,
2898                },
2899                EntityData::Row(row),
2900            )
2901        })
2902        .collect();
2903
2904    let ids = store
2905        .bulk_insert(&collection, entities)
2906        .map_err(|e| crate::RedDBError::Internal(format!("{e:?}")))?;
2907
2908    if has_secondary_indexes {
2909        let index_rows: Vec<(EntityId, Vec<(String, crate::storage::schema::Value)>)> = ids
2910            .iter()
2911            .zip(field_snapshots)
2912            .map(|(id, fields)| (*id, fields))
2913            .collect();
2914        runtime
2915            .index_store_ref()
2916            .index_entity_insert_batch(&collection, &index_rows)
2917            .map_err(crate::RedDBError::Internal)?;
2918    }
2919
2920    runtime.invalidate_result_cache();
2921    runtime.cdc_emit_insert_batch_no_cache_invalidate(&collection, &ids, "table");
2922
2923    Ok(ids
2924        .into_iter()
2925        .map(|id| CreateEntityOutput { id, entity: None })
2926        .collect())
2927}
2928
2929impl RuntimeEntityPort for RedDBRuntime {
2930    fn create_row(&self, input: CreateRowInput) -> RedDBResult<CreateEntityOutput> {
2931        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2932        let db = self.db();
2933        let CreateRowInput {
2934            collection,
2935            fields,
2936            metadata: input_metadata,
2937            node_links,
2938            vector_links,
2939        } = input;
2940        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2941        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2942        let mut metadata = input_metadata;
2943        apply_collection_default_ttl(&db, &collection, &mut metadata);
2944        let fields = contract.normalize_insert_fields(fields)?;
2945        contract.enforce_row_uniqueness(&fields, None)?;
2946        // Route through MutationEngine for unified hot path.
2947        let engine = self.mutation_engine();
2948        let result = engine.apply(
2949            collection.clone(),
2950            vec![crate::runtime::mutation::MutationRow {
2951                fields,
2952                metadata,
2953                node_links,
2954                vector_links,
2955            }],
2956        )?;
2957        let id = result.ids[0];
2958        // Perf: `db.get(id)` does a *cross-collection* scan (get_any)
2959        // that also takes a write lock on the entity cache. We know
2960        // the collection — hit the manager directly. Cuts
2961        // create_row() p50 roughly in half on the hot path.
2962        Ok(CreateEntityOutput {
2963            id,
2964            entity: db.store().get(&collection, id),
2965        })
2966    }
2967
2968    fn create_rows_batch(
2969        &self,
2970        input: CreateRowsBatchInput,
2971    ) -> RedDBResult<Vec<CreateEntityOutput>> {
2972        if input.rows.is_empty() {
2973            return Ok(Vec::new());
2974        }
2975        self.check_batch_size(input.rows.len())?;
2976        self.check_db_size()?;
2977
2978        let db = self.db();
2979        let collection = input.collection;
2980        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2981        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2982
2983        let mut prepared_rows = Vec::with_capacity(input.rows.len());
2984        let mut uniqueness_rows = Vec::with_capacity(input.rows.len());
2985        for row in input.rows {
2986            if row.collection != collection {
2987                return Err(crate::RedDBError::Query(format!(
2988                    "batch row collection mismatch: expected '{}', got '{}'",
2989                    collection, row.collection
2990                )));
2991            }
2992
2993            let mut metadata = row.metadata;
2994            apply_collection_default_ttl(&db, &collection, &mut metadata);
2995            let fields = contract.normalize_insert_fields(row.fields)?;
2996            contract.enforce_row_uniqueness(&fields, None)?;
2997            uniqueness_rows.push(fields.clone());
2998            prepared_rows.push((fields, metadata, row.node_links, row.vector_links));
2999        }
3000
3001        contract.enforce_batch_uniqueness(&uniqueness_rows)?;
3002
3003        // Route through MutationEngine: single bulk_insert + one CDC batch
3004        // instead of N separate cdc_emit() calls (each acquires a write lock).
3005        let engine = {
3006            let e = self.mutation_engine();
3007            if input.suppress_events {
3008                e.with_suppress_events()
3009            } else {
3010                e
3011            }
3012        };
3013        let mutation_rows: Vec<crate::runtime::mutation::MutationRow> = prepared_rows
3014            .into_iter()
3015            .map(|(fields, metadata, node_links, vector_links)| {
3016                crate::runtime::mutation::MutationRow {
3017                    fields,
3018                    metadata,
3019                    node_links,
3020                    vector_links,
3021                }
3022            })
3023            .collect();
3024
3025        let result = engine
3026            .apply(collection.clone(), mutation_rows)
3027            .map_err(|e| crate::RedDBError::Internal(e.to_string()))?;
3028
3029        let store = db.store();
3030        Ok(result
3031            .ids
3032            .into_iter()
3033            .map(|id| CreateEntityOutput {
3034                id,
3035                entity: store.get(&collection, id),
3036            })
3037            .collect())
3038    }
3039
3040    fn create_rows_batch_prevalidated_columnar(
3041        &self,
3042        collection: String,
3043        column_names: std::sync::Arc<Vec<String>>,
3044        rows: Vec<Vec<crate::storage::schema::Value>>,
3045    ) -> RedDBResult<usize> {
3046        create_rows_batch_prevalidated_columnar_with_outputs(self, collection, column_names, rows)
3047            .map(|outputs| outputs.len())
3048    }
3049
3050    fn create_rows_batch_columnar(
3051        &self,
3052        collection: String,
3053        column_names: std::sync::Arc<Vec<String>>,
3054        rows: Vec<Vec<crate::storage::schema::Value>>,
3055    ) -> RedDBResult<usize> {
3056        self.create_rows_batch_columnar_with_outputs(collection, column_names, rows)
3057            .map(|outputs| outputs.len())
3058    }
3059
3060    fn create_rows_batch_columnar_with_outputs(
3061        &self,
3062        collection: String,
3063        column_names: std::sync::Arc<Vec<String>>,
3064        rows: Vec<Vec<crate::storage::schema::Value>>,
3065    ) -> RedDBResult<Vec<CreateEntityOutput>> {
3066        if rows.is_empty() {
3067            return Ok(Vec::new());
3068        }
3069        self.check_batch_size(rows.len())?;
3070        self.check_db_size()?;
3071
3072        let db = self.db();
3073        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
3074        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
3075
3076        // Fast path: when the collection carries no contract (or the
3077        // contract has no declared columns) `normalize_insert_fields`
3078        // is a no-op and `enforce_row_uniqueness` finds no unique
3079        // columns to check. Skip the per-row `(String, Value)` tuple
3080        // materialisation entirely and route through the prevalidated
3081        // columnar kernel — same write, CDC, and index path, just
3082        // without the wasted (String, Value) clones. This is the
3083        // bench `bench_users` shape (no contract declared by the
3084        // adapter's `setup_schema`).
3085        let needs_normalisation = match db.collection_contract(&collection) {
3086            Some(c) => {
3087                c.declared_model == crate::catalog::CollectionModel::Table
3088                    && (!c.declared_columns.is_empty()
3089                        || c.table_def
3090                            .as_ref()
3091                            .map(|t| !t.columns.is_empty())
3092                            .unwrap_or(false))
3093            }
3094            None => false,
3095        };
3096        if !needs_normalisation {
3097            return create_rows_batch_prevalidated_columnar_with_outputs(
3098                self,
3099                collection,
3100                column_names,
3101                rows,
3102            );
3103        }
3104
3105        // Slow path: contract requires per-row normalisation /
3106        // uniqueness checks. Materialise `Vec<(String, Value)>` from
3107        // the columnar layout (this still pays N×ncols `String::clone`
3108        // — but it's deferred out of the wire decoder hot loop and
3109        // happens behind a runtime API, not the per-row decode loop)
3110        // and fall through to the existing `create_rows_batch` path.
3111        let ncols = column_names.len();
3112        let tuple_rows: Vec<CreateRowInput> = rows
3113            .into_iter()
3114            .map(|values| {
3115                let mut fields: Vec<(String, crate::storage::schema::Value)> =
3116                    Vec::with_capacity(ncols);
3117                for (name, value) in column_names.iter().zip(values) {
3118                    fields.push((name.clone(), value));
3119                }
3120                CreateRowInput {
3121                    collection: collection.clone(),
3122                    fields,
3123                    metadata: Vec::new(),
3124                    node_links: Vec::new(),
3125                    vector_links: Vec::new(),
3126                }
3127            })
3128            .collect();
3129        self.create_rows_batch(CreateRowsBatchInput {
3130            collection,
3131            rows: tuple_rows,
3132            suppress_events: false,
3133        })
3134    }
3135
3136    fn create_rows_batch_prevalidated(&self, input: CreateRowsBatchInput) -> RedDBResult<usize> {
3137        if input.rows.is_empty() {
3138            return Ok(0);
3139        }
3140        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3141        self.check_batch_size(input.rows.len())?;
3142        self.check_db_size()?;
3143
3144        let db = self.db();
3145        let collection = input.collection;
3146        // Still verify the collection's declared model before we blast
3147        // rows at it — this one-off check is O(1), independent of
3148        // ncols, and catches schema-kind mismatches that the client
3149        // can't always see.
3150        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
3151        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
3152
3153        // Hoist the per-collection default TTL lookup out of the
3154        // per-row loop — it only depends on the collection, not on
3155        // individual rows, and the old path did one HashMap read per
3156        // row (25k reads for a 25k typed_insert bulk). Fetch it once,
3157        // apply to any row whose metadata doesn't already carry a TTL.
3158        let default_ttl_ms = db.collection_default_ttl_ms(&collection);
3159
3160        let mutation_rows: Vec<crate::runtime::mutation::MutationRow> =
3161            Vec::with_capacity(input.rows.len());
3162        let mut mutation_rows = mutation_rows;
3163        for row in input.rows {
3164            if row.collection != collection {
3165                return Err(crate::RedDBError::Query(format!(
3166                    "batch row collection mismatch: expected '{}', got '{}'",
3167                    collection, row.collection
3168                )));
3169            }
3170            let mut metadata = row.metadata;
3171            if let Some(ttl) = default_ttl_ms {
3172                if !has_internal_ttl_metadata(&metadata) {
3173                    metadata.push((
3174                        "_ttl_ms".to_string(),
3175                        if ttl <= i64::MAX as u64 {
3176                            MetadataValue::Int(ttl as i64)
3177                        } else {
3178                            MetadataValue::Timestamp(ttl)
3179                        },
3180                    ));
3181                }
3182            }
3183            mutation_rows.push(crate::runtime::mutation::MutationRow {
3184                fields: row.fields,
3185                metadata,
3186                node_links: row.node_links,
3187                vector_links: row.vector_links,
3188            });
3189        }
3190
3191        let engine = self.mutation_engine();
3192        let result = engine
3193            .apply(collection, mutation_rows)
3194            .map_err(|e| crate::RedDBError::Internal(e.to_string()))?;
3195        Ok(result.ids.len())
3196    }
3197
3198    fn create_node(&self, input: CreateNodeInput) -> RedDBResult<CreateEntityOutput> {
3199        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3200        crate::reserved_fields::ensure_no_reserved_public_item_fields(
3201            input.properties.iter().map(|(key, _)| key.as_str()),
3202            &format!("node '{}'", input.collection),
3203        )?;
3204        ensure_non_tree_reserved_metadata_entries(&input.metadata)?;
3205        self.create_node_unchecked(input)
3206    }
3207
3208    fn create_edge(&self, input: CreateEdgeInput) -> RedDBResult<CreateEntityOutput> {
3209        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3210        crate::reserved_fields::ensure_no_reserved_public_item_fields(
3211            input.properties.iter().map(|(key, _)| key.as_str()),
3212            &format!("edge '{}'", input.collection),
3213        )?;
3214        ensure_non_tree_structural_edge_label(&input.label)?;
3215        ensure_non_tree_reserved_metadata_entries(&input.metadata)?;
3216        self.create_edge_unchecked(input)
3217    }
3218
3219    fn create_vector(&self, input: CreateVectorInput) -> RedDBResult<CreateEntityOutput> {
3220        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3221        let db = self.db();
3222        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3223        contract.ensure_model(crate::catalog::CollectionModel::Vector)?;
3224        ensure_vector_dimension_contract(&db, &input.collection, input.dense.len())?;
3225        let mut metadata = input.metadata;
3226        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
3227        let mut builder = db.vector(&input.collection).dense(input.dense);
3228
3229        if let Some(content) = input.content {
3230            builder = builder.content(content);
3231        }
3232
3233        for (key, value) in metadata {
3234            builder = builder.metadata(key, value);
3235        }
3236
3237        if let Some(link_row) = input.link_row {
3238            builder = builder.link_to_table(link_row);
3239        }
3240
3241        if let Some(link_node) = input.link_node {
3242            builder = builder.link_to_node(link_node);
3243        }
3244
3245        let id = builder.save()?;
3246        let dense_for_turbo = match db.store().get(&input.collection, id).map(|e| e.data) {
3247            Some(crate::storage::unified::EntityData::Vector(data)) => Some(data.dense.clone()),
3248            _ => None,
3249        };
3250        // Phase 1.1 MVCC universal: stamp xmin on the vector so
3251        // concurrent ANN scans hide it until the transaction commits.
3252        self.stamp_xmin_if_in_txn(&input.collection, id);
3253        refresh_context_index(&db, &input.collection, id)?;
3254        // Issue #693 — vector.turbo write path. Order matters and
3255        // matches the write sequence the next slice (#673) will
3256        // attach kill-points to: WAL append → in-memory index update
3257        // → TurboExtent append. The legacy `vector` path is the
3258        // `None` branch and is untouched.
3259        if let (Some(state), Some(dense)) = (db.turbo_state(&input.collection), dense_for_turbo) {
3260            // Lazy populate so an INSERT after restart sees the
3261            // pre-restart vectors in the index before appending its
3262            // own.
3263            state.ensure_populated(&db.store(), &input.collection);
3264            // Issue #694 — named crash boundaries for #673.
3265            use crate::runtime::turbo_crash_inject::{fire, InjectionPoint};
3266            fire(InjectionPoint::BeforeWalFsync);
3267            let _ = db
3268                .store()
3269                .append_vector_insert_record(&input.collection, id.raw(), &dense);
3270            fire(InjectionPoint::BeforeIndexCommit);
3271            let mut index = state.index.lock();
3272            index.insert(id, dense.clone());
3273            // Mirror the encoded codes into the per-collection
3274            // TurboExtent when one is available. The bytes are the
3275            // 4-bit-packed code groups (`n_byte_groups`) plus the
3276            // little-endian L2 norm — same shape `BlockedCodeStorage`
3277            // appends in-memory. Failures are non-fatal in this
3278            // slice; durability of the extent is owned by #673.
3279            fire(InjectionPoint::BeforeExtentFsync);
3280            if let Some(extent) = state.extent.lock().as_mut() {
3281                let packed = index.encode_for_extent(&dense);
3282                let _ = extent.append(&packed);
3283            }
3284        }
3285        self.cdc_emit(
3286            crate::replication::cdc::ChangeOperation::Insert,
3287            &input.collection,
3288            id.raw(),
3289            "vector",
3290        );
3291        Ok(CreateEntityOutput {
3292            id,
3293            entity: db.store().get(&input.collection, id),
3294        })
3295    }
3296
3297    fn create_document(&self, input: CreateDocumentInput) -> RedDBResult<CreateEntityOutput> {
3298        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3299        let db = self.db();
3300        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3301        contract.ensure_model(crate::catalog::CollectionModel::Document)?;
3302
3303        if let JsonValue::Object(ref map) = input.body {
3304            crate::reserved_fields::ensure_no_reserved_public_item_fields(
3305                map.keys().map(String::as_str),
3306                &format!("document '{}'", input.collection),
3307            )?;
3308        }
3309
3310        // Serialize the full body for the "body" field. Behind the
3311        // `storage.binary_document_body` flag (PRD-1398) this is the native binary
3312        // container; otherwise plain JSON. Reads decode either form to JSON.
3313        let binary_body = self.binary_document_body_enabled();
3314        let body_bytes = crate::document_body::serialize_document_body(&input.body, binary_body)?;
3315        let fields: Vec<(String, crate::storage::schema::Value)> = vec![(
3316            "body".to_string(),
3317            crate::storage::schema::Value::Json(body_bytes),
3318        )];
3319
3320        let mut metadata = input.metadata;
3321        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
3322        let columns: Vec<(&str, crate::storage::schema::Value)> = fields
3323            .iter()
3324            .map(|(key, value)| (key.as_str(), value.clone()))
3325            .collect();
3326        let mut builder = db.row(&input.collection, columns);
3327
3328        for (key, value) in metadata {
3329            builder = builder.metadata(key, value);
3330        }
3331
3332        for node in input.node_links {
3333            builder = builder.link_to_node(node);
3334        }
3335
3336        for vector in input.vector_links {
3337            builder = builder.link_to_vector(vector);
3338        }
3339
3340        let id = builder.save()?;
3341        // Phase 1.1 MVCC universal: stamp xmin on the document.
3342        self.stamp_xmin_if_in_txn(&input.collection, id);
3343        self.index_store_ref()
3344            .index_entity_insert(&input.collection, id, &fields)
3345            .map_err(crate::RedDBError::Internal)?;
3346        refresh_context_index(&db, &input.collection, id)?;
3347        self.cdc_emit(
3348            crate::replication::cdc::ChangeOperation::Insert,
3349            &input.collection,
3350            id.raw(),
3351            "document",
3352        );
3353        Ok(CreateEntityOutput {
3354            id,
3355            entity: db.store().get(&input.collection, id),
3356        })
3357    }
3358
3359    fn create_kv(&self, input: CreateKvInput) -> RedDBResult<CreateEntityOutput> {
3360        let db = self.db();
3361        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3362        let declared_model = db
3363            .collection_contract(&input.collection)
3364            .map(|contract| contract.declared_model);
3365        let value = if declared_model == Some(crate::catalog::CollectionModel::Vault) {
3366            contract.ensure_model(crate::catalog::CollectionModel::Vault)?;
3367            self.seal_vault_value(&input.collection, input.value)?
3368        } else {
3369            contract.ensure_model(crate::catalog::CollectionModel::Kv)?;
3370            input.value
3371        };
3372        let fields = vec![
3373            (
3374                "key".to_string(),
3375                crate::storage::schema::Value::text(input.key),
3376            ),
3377            ("value".to_string(), value),
3378        ];
3379        let collection = input.collection;
3380        let result = self.mutation_engine().apply(
3381            collection.clone(),
3382            vec![crate::runtime::mutation::MutationRow {
3383                fields,
3384                metadata: input.metadata,
3385                node_links: Vec::new(),
3386                vector_links: Vec::new(),
3387            }],
3388        )?;
3389        let id = result.ids[0];
3390        Ok(CreateEntityOutput {
3391            id,
3392            entity: db.store().get(&collection, id),
3393        })
3394    }
3395
3396    fn create_timeseries_point(
3397        &self,
3398        input: CreateTimeSeriesPointInput,
3399    ) -> RedDBResult<CreateEntityOutput> {
3400        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3401        let db = self.db();
3402        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3403        contract.ensure_model(crate::catalog::CollectionModel::TimeSeries)?;
3404
3405        let mut fields = vec![
3406            (
3407                "metric".to_string(),
3408                crate::storage::schema::Value::text(input.metric),
3409            ),
3410            (
3411                "value".to_string(),
3412                crate::storage::schema::Value::Float(input.value),
3413            ),
3414        ];
3415
3416        if let Some(timestamp_ns) = input.timestamp_ns {
3417            fields.push((
3418                "timestamp".to_string(),
3419                crate::storage::schema::Value::UnsignedInteger(timestamp_ns),
3420            ));
3421        }
3422
3423        if !input.tags.is_empty() {
3424            let tags_json = JsonValue::Object(
3425                input
3426                    .tags
3427                    .into_iter()
3428                    .map(|(key, value)| (key, JsonValue::String(value)))
3429                    .collect(),
3430            );
3431            let tags_bytes = json_to_vec(&tags_json).map_err(|err| {
3432                crate::RedDBError::Query(format!("failed to serialize timeseries tags: {err}"))
3433            })?;
3434            fields.push((
3435                "tags".to_string(),
3436                crate::storage::schema::Value::Json(tags_bytes),
3437            ));
3438        }
3439
3440        let collection = input.collection;
3441        let id = self.insert_timeseries_point(&collection, fields, input.metadata)?;
3442        // Phase 1.1 MVCC universal: stamp xmin on the point so
3443        // concurrent range scans hide it until COMMIT.
3444        self.stamp_xmin_if_in_txn(&collection, id);
3445        refresh_context_index(&db, &collection, id)?;
3446
3447        Ok(CreateEntityOutput {
3448            id,
3449            entity: db.store().get(&collection, id),
3450        })
3451    }
3452
3453    fn get_kv(
3454        &self,
3455        collection: &str,
3456        key: &str,
3457    ) -> RedDBResult<Option<(crate::storage::schema::Value, crate::storage::EntityId)>> {
3458        let db = self.db();
3459        ensure_collection_model_read(&db, collection, crate::catalog::CollectionModel::Kv)?;
3460        let store = db.store();
3461        let Some(manager) = store.get_collection(collection) else {
3462            return Ok(None);
3463        };
3464        let entities = manager.query_all(|_| true);
3465        for entity in entities {
3466            if let crate::storage::EntityData::Row(ref row) = entity.data {
3467                if let Some(ref named) = row.named {
3468                    if let Some(crate::storage::schema::Value::Text(ref k)) = named.get("key") {
3469                        if &**k == key {
3470                            let value = named
3471                                .get("value")
3472                                .cloned()
3473                                .unwrap_or(crate::storage::schema::Value::Null);
3474                            return Ok(Some((value, entity.id)));
3475                        }
3476                    }
3477                }
3478            }
3479        }
3480        Ok(None)
3481    }
3482
3483    fn delete_kv(&self, collection: &str, key: &str) -> RedDBResult<bool> {
3484        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3485        let found = self.get_kv(collection, key)?;
3486        if let Some((_, id)) = found {
3487            let db = self.db();
3488            let store = db.store();
3489            let deleted = store
3490                .delete(collection, id)
3491                .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
3492            if deleted {
3493                store.context_index().remove_entity(id);
3494            }
3495            Ok(deleted)
3496        } else {
3497            Ok(false)
3498        }
3499    }
3500
3501    fn patch_entity(&self, input: PatchEntityInput) -> RedDBResult<CreateEntityOutput> {
3502        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3503        let PatchEntityInput {
3504            collection,
3505            id,
3506            payload,
3507            operations,
3508        } = input;
3509        let db = self.db();
3510        let store = db.store();
3511        let Some(manager) = store.get_collection(&collection) else {
3512            return Err(crate::RedDBError::NotFound(format!(
3513                "collection not found: {collection}"
3514            )));
3515        };
3516        let Some(entity) = manager.get(id) else {
3517            return Err(crate::RedDBError::NotFound(format!(
3518                "entity not found: {}",
3519                id.raw()
3520            )));
3521        };
3522        self.apply_loaded_patch_entity(collection, entity, payload, operations)
3523    }
3524
3525    fn delete_entity(&self, input: DeleteEntityInput) -> RedDBResult<DeleteEntityOutput> {
3526        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3527        let store = self.db().store();
3528        // Snapshot row fields before delete so we can mirror the removal
3529        // into every secondary index. The fetch is best-effort: if the
3530        // entity is already gone, the delete below is a no-op anyway.
3531        let pre_delete_fields = store
3532            .get(&input.collection, input.id)
3533            .as_ref()
3534            .map(entity_row_fields_snapshot)
3535            .unwrap_or_default();
3536        // Store delete first (source of truth). Crash between here and index removal
3537        // leaves the entity invisible to most queries but recoverable; the reverse
3538        // (remove index first, then crash) leaves the entity permanently orphaned.
3539        let deleted = store
3540            .delete(&input.collection, input.id)
3541            .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
3542        if deleted {
3543            store.context_index().remove_entity(input.id);
3544            // Secondary index maintenance — surface only registry-shape
3545            // errors; missing-index removals are tolerated inside the call.
3546            if !pre_delete_fields.is_empty() {
3547                self.index_store_ref()
3548                    .index_entity_delete(&input.collection, input.id, &pre_delete_fields)
3549                    .map_err(crate::RedDBError::Internal)?;
3550            }
3551            self.cdc_emit(
3552                crate::replication::cdc::ChangeOperation::Delete,
3553                &input.collection,
3554                input.id.raw(),
3555                "entity",
3556            );
3557        }
3558        Ok(DeleteEntityOutput {
3559            deleted,
3560            id: input.id,
3561        })
3562    }
3563}