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::Enum => "enum",
1111        DataType::Array => "array",
1112        DataType::TimestampMs => "timestamp_ms",
1113        DataType::Ipv4 => "ipv4",
1114        DataType::Ipv6 => "ipv6",
1115        DataType::Subnet => "subnet",
1116        DataType::Port => "port",
1117        DataType::Latitude => "latitude",
1118        DataType::Longitude => "longitude",
1119        DataType::GeoPoint => "geopoint",
1120        DataType::Country2 => "country2",
1121        DataType::Country3 => "country3",
1122        DataType::Lang2 => "lang2",
1123        DataType::Lang5 => "lang5",
1124        DataType::Currency => "currency",
1125        DataType::AssetCode => "asset_code",
1126        DataType::Money => "money",
1127        DataType::ColorAlpha => "color_alpha",
1128        DataType::BigInt => "bigint",
1129        DataType::KeyRef => "keyref",
1130        DataType::DocRef => "docref",
1131        DataType::TableRef => "tableref",
1132        DataType::PageRef => "pageref",
1133        DataType::Secret => "secret",
1134        DataType::Password => "password",
1135        DataType::TextZstd => "text",
1136        DataType::BlobZstd => "blob",
1137    }
1138}
1139
1140fn value_matches_declared_type(value: &Value, target: DataType) -> bool {
1141    matches!(
1142        (value, target),
1143        (Value::Null, _)
1144            | (Value::Integer(_), DataType::Integer)
1145            | (Value::UnsignedInteger(_), DataType::UnsignedInteger)
1146            | (Value::Float(_), DataType::Float)
1147            | (Value::Text(_), DataType::Text)
1148            | (Value::Blob(_), DataType::Blob)
1149            | (Value::Boolean(_), DataType::Boolean)
1150            | (Value::Timestamp(_), DataType::Timestamp)
1151            | (Value::Duration(_), DataType::Duration)
1152            | (Value::IpAddr(_), DataType::IpAddr)
1153            | (Value::MacAddr(_), DataType::MacAddr)
1154            | (Value::Vector(_), DataType::Vector)
1155            | (Value::Json(_), DataType::Json)
1156            | (Value::Uuid(_), DataType::Uuid)
1157            | (Value::NodeRef(_), DataType::NodeRef)
1158            | (Value::EdgeRef(_), DataType::EdgeRef)
1159            | (Value::VectorRef(_, _), DataType::VectorRef)
1160            | (Value::RowRef(_, _), DataType::RowRef)
1161            | (Value::Color(_), DataType::Color)
1162            | (Value::Email(_), DataType::Email)
1163            | (Value::Url(_), DataType::Url)
1164            | (Value::Phone(_), DataType::Phone)
1165            | (Value::Semver(_), DataType::Semver)
1166            | (Value::Cidr(_, _), DataType::Cidr)
1167            | (Value::Date(_), DataType::Date)
1168            | (Value::Time(_), DataType::Time)
1169            | (Value::Decimal(_), DataType::Decimal)
1170            | (Value::EnumValue(_), DataType::Enum)
1171            | (Value::Array(_), DataType::Array)
1172            | (Value::TimestampMs(_), DataType::TimestampMs)
1173            | (Value::Ipv4(_), DataType::Ipv4)
1174            | (Value::Ipv6(_), DataType::Ipv6)
1175            | (Value::Subnet(_, _), DataType::Subnet)
1176            | (Value::Port(_), DataType::Port)
1177            | (Value::Latitude(_), DataType::Latitude)
1178            | (Value::Longitude(_), DataType::Longitude)
1179            | (Value::GeoPoint(_, _), DataType::GeoPoint)
1180            | (Value::Country2(_), DataType::Country2)
1181            | (Value::Country3(_), DataType::Country3)
1182            | (Value::Lang2(_), DataType::Lang2)
1183            | (Value::Lang5(_), DataType::Lang5)
1184            | (Value::Currency(_), DataType::Currency)
1185            | (Value::ColorAlpha(_), DataType::ColorAlpha)
1186            | (Value::BigInt(_), DataType::BigInt)
1187            | (Value::KeyRef(_, _), DataType::KeyRef)
1188            | (Value::DocRef(_, _), DataType::DocRef)
1189            | (Value::TableRef(_), DataType::TableRef)
1190            | (Value::PageRef(_), DataType::PageRef)
1191            | (Value::Secret(_), DataType::Secret)
1192            | (Value::Password(_), DataType::Password)
1193    )
1194}
1195
1196fn value_to_coercion_input(value: &Value) -> Option<String> {
1197    match value {
1198        Value::Null => None,
1199        Value::Integer(value) => Some(value.to_string()),
1200        Value::UnsignedInteger(value) => Some(value.to_string()),
1201        Value::Float(value) => Some(value.to_string()),
1202        Value::Text(value) => Some(value.to_string()),
1203        Value::Blob(value) => String::from_utf8(value.clone()).ok(),
1204        Value::Boolean(value) => Some(value.to_string()),
1205        Value::Timestamp(value) => Some(value.to_string()),
1206        Value::Duration(value) => Some(value.to_string()),
1207        Value::IpAddr(value) => Some(value.to_string()),
1208        Value::MacAddr(value) => Some(format!(
1209            "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
1210            value[0], value[1], value[2], value[3], value[4], value[5]
1211        )),
1212        Value::Json(value) => Some(String::from_utf8_lossy(value).to_string()),
1213        Value::Email(value) => Some(value.clone()),
1214        Value::Url(value) => Some(value.clone()),
1215        Value::Phone(value) => Some(value.to_string()),
1216        Value::Semver(value) => Some(format!(
1217            "{}.{}.{}",
1218            value / 1_000_000,
1219            (value / 1_000) % 1_000,
1220            value % 1_000
1221        )),
1222        Value::Date(value) => Some(value.to_string()),
1223        Value::Time(value) => Some(value.to_string()),
1224        Value::Decimal(value) => Some(value.to_string()),
1225        Value::TimestampMs(value) => Some(value.to_string()),
1226        Value::Ipv4(value) => Some(format!(
1227            "{}.{}.{}.{}",
1228            (value >> 24) & 0xFF,
1229            (value >> 16) & 0xFF,
1230            (value >> 8) & 0xFF,
1231            value & 0xFF
1232        )),
1233        Value::Port(value) => Some(value.to_string()),
1234        Value::Latitude(value) => Some((*value as f64 / 1_000_000.0).to_string()),
1235        Value::Longitude(value) => Some((*value as f64 / 1_000_000.0).to_string()),
1236        Value::GeoPoint(lat, lon) => Some(format!(
1237            "{},{}",
1238            *lat as f64 / 1_000_000.0,
1239            *lon as f64 / 1_000_000.0
1240        )),
1241        Value::BigInt(value) => Some(value.to_string()),
1242        Value::TableRef(value) => Some(value.clone()),
1243        Value::PageRef(value) => Some(value.to_string()),
1244        Value::Password(value) => Some(value.clone()),
1245        _ => None,
1246    }
1247}
1248
1249fn dedupe_modified_columns(mut modified_columns: Vec<String>) -> Vec<String> {
1250    if modified_columns.is_empty() {
1251        return modified_columns;
1252    }
1253
1254    let mut unique = Vec::with_capacity(modified_columns.len());
1255    for column in modified_columns.drain(..) {
1256        if !unique
1257            .iter()
1258            .any(|existing: &String| existing.eq_ignore_ascii_case(&column))
1259        {
1260            unique.push(column);
1261        }
1262    }
1263    unique
1264}
1265
1266fn reject_document_array_position_path(path: &[String]) -> RedDBResult<()> {
1267    if path.iter().any(|segment| segment.parse::<usize>().is_ok()) {
1268        return Err(crate::RedDBError::Query(
1269            "array positional document patch paths are unsupported; replace the array or full document body instead"
1270                .to_string(),
1271        ));
1272    }
1273    Ok(())
1274}
1275
1276/// Reject a document body `set` whose path would traverse *through* an existing
1277/// non-object value (e.g. `SET a.b = …` where `a` is already a scalar). This
1278/// mirrors the HTTP JSON-patch contract: intermediate objects are created only
1279/// where a slot is absent, never by silently clobbering a scalar. `body` is the
1280/// pre-merge document body.
1281fn reject_document_patch_path_through_scalar(body: &JsonValue, path: &[String]) -> RedDBResult<()> {
1282    if path.len() < 2 {
1283        return Ok(());
1284    }
1285    let scalar_error = |prefix: &str| {
1286        crate::RedDBError::Query(format!(
1287            "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",
1288            path.join("."),
1289            prefix
1290        ))
1291    };
1292    let mut current = body;
1293    // Walk the parent path (every segment but the leaf). Each container we index
1294    // into must be an object; an absent slot is fine (the merge creates it).
1295    for (idx, segment) in path[..path.len() - 1].iter().enumerate() {
1296        let JsonValue::Object(map) = current else {
1297            return Err(scalar_error(&path[..idx].join(".")));
1298        };
1299        match map.get(segment) {
1300            Some(next) => current = next,
1301            None => return Ok(()),
1302        }
1303    }
1304    // `current` is now the leaf's parent container; it must be an object.
1305    if !matches!(current, JsonValue::Object(_)) {
1306        return Err(scalar_error(&path[..path.len() - 1].join(".")));
1307    }
1308    Ok(())
1309}
1310
1311/// Apply document body `set`/`unset` operations, enforcing the shared document
1312/// patch contract first: array-positional paths and paths that traverse through
1313/// a scalar are rejected with clear errors before any mutation touches `body`.
1314/// Used by both the SQL nested-`SET` executor and the HTTP JSON-patch operation
1315/// path so the two surfaces stay consistent (ADR 0067).
1316fn apply_document_body_patch_operations(
1317    body: &mut JsonValue,
1318    operations: &[PatchEntityOperation],
1319) -> RedDBResult<()> {
1320    for op in operations {
1321        reject_document_array_position_path(&op.path)?;
1322        if matches!(op.op, PatchEntityOperationType::Set) {
1323            reject_document_patch_path_through_scalar(body, &op.path)?;
1324        }
1325    }
1326    apply_patch_operations_to_json(body, operations).map_err(crate::RedDBError::Query)
1327}
1328
1329fn document_body_from_named(fields: &HashMap<String, Value>) -> RedDBResult<JsonValue> {
1330    match fields.get("body") {
1331        Some(Value::Json(bytes)) => {
1332            // The stored body may be the native binary container (PRD-1398) or
1333            // plain JSON; decode either form back to JSON.
1334            if let Some(body) = crate::document_body::decode_container_to_json(bytes) {
1335                Ok(body)
1336            } else {
1337                crate::json::from_slice(bytes).map_err(|err| {
1338                    crate::RedDBError::Query(format!("failed to decode document body: {err}"))
1339                })
1340            }
1341        }
1342        Some(_) => Err(crate::RedDBError::Query(
1343            "document body field must contain JSON".to_string(),
1344        )),
1345        None => Ok(JsonValue::Object(Default::default())),
1346    }
1347}
1348
1349fn document_body_from_assignment(value: &Value) -> RedDBResult<JsonValue> {
1350    match value {
1351        Value::Json(bytes) | Value::Blob(bytes) => crate::json::from_slice(bytes)
1352            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1353        Value::Text(text) => crate::json::from_str(text.as_ref())
1354            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1355        Value::Integer(value) => crate::json::from_str(&value.to_string())
1356            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1357        Value::UnsignedInteger(value) => crate::json::from_str(&value.to_string())
1358            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1359        Value::Float(value) => crate::json::from_str(&value.to_string())
1360            .map_err(|err| crate::RedDBError::Query(format!("invalid JSON body: {err}"))),
1361        other => Err(crate::RedDBError::Query(format!(
1362            "column 'body' expected JSON body, got {other:?}"
1363        ))),
1364    }
1365}
1366
1367fn document_body_set_operation(column: &str, value: Value) -> PatchEntityOperation {
1368    PatchEntityOperation {
1369        op: PatchEntityOperationType::Set,
1370        path: column.split('.').map(str::to_string).collect(),
1371        value: Some(crate::presentation::entity_json::storage_value_to_json(
1372            &value,
1373        )),
1374    }
1375}
1376
1377fn replace_document_fields_body(
1378    fields: &mut HashMap<String, Value>,
1379    body: JsonValue,
1380    binary: bool,
1381    modified_columns: &mut Vec<String>,
1382) -> RedDBResult<()> {
1383    modified_columns.push("body".to_string());
1384
1385    let old_keys: Vec<String> = fields
1386        .keys()
1387        .filter(|key| key.as_str() != "body")
1388        .cloned()
1389        .collect();
1390    for key in old_keys {
1391        fields.remove(&key);
1392        modified_columns.push(key);
1393    }
1394
1395    let body_bytes = crate::document_body::serialize_document_body(&body, binary)?;
1396    fields.insert("body".to_string(), Value::Json(body_bytes));
1397
1398    Ok(())
1399}
1400
1401fn ensure_no_reserved_document_body_fields(body: &JsonValue, collection: &str) -> RedDBResult<()> {
1402    if let JsonValue::Object(map) = body {
1403        crate::reserved_fields::ensure_no_reserved_public_item_fields(
1404            map.keys().map(String::as_str),
1405            &format!("document '{collection}'"),
1406        )?;
1407    }
1408    Ok(())
1409}
1410
1411fn replace_document_row_body(
1412    row: &mut crate::storage::unified::entity::RowData,
1413    body: JsonValue,
1414    collection: &str,
1415    binary: bool,
1416    modified_columns: &mut Vec<String>,
1417) -> RedDBResult<()> {
1418    ensure_no_reserved_document_body_fields(&body, collection)?;
1419    let fields = row.named.get_or_insert_with(Default::default);
1420    replace_document_fields_body(fields, body, binary, modified_columns)?;
1421
1422    // Full body replacement rebuilds the document from the canonical `body`.
1423    // Stale positional/schema values would otherwise leak back through JSON
1424    // presentation even after the named map was pruned.
1425    row.columns.clear();
1426    row.schema = None;
1427    Ok(())
1428}
1429
1430fn document_merge_patch_payload(payload: &JsonValue) -> &JsonValue {
1431    if let JsonValue::Object(object) = payload {
1432        if object.len() == 1 {
1433            if let Some(body) = object.get("body") {
1434                return body;
1435            }
1436        }
1437    }
1438    payload
1439}
1440
1441fn apply_document_json_merge_patch(body: &mut JsonValue, patch: &JsonValue) -> RedDBResult<()> {
1442    if !matches!(patch, JsonValue::Object(_)) {
1443        *body = patch.clone();
1444        return Ok(());
1445    }
1446
1447    if matches!(patch, JsonValue::Object(object) if object.is_empty()) {
1448        if !matches!(body, JsonValue::Object(_)) {
1449            *body = JsonValue::Object(Default::default());
1450        }
1451        return Ok(());
1452    }
1453
1454    let mut operations = Vec::new();
1455    collect_document_json_merge_patch_operations(body, Vec::new(), patch, &mut operations);
1456    apply_patch_operations_to_json(body, &operations).map_err(crate::RedDBError::Query)
1457}
1458
1459fn collect_document_json_merge_patch_operations(
1460    target: &JsonValue,
1461    path: Vec<String>,
1462    patch: &JsonValue,
1463    operations: &mut Vec<PatchEntityOperation>,
1464) {
1465    let JsonValue::Object(patch_object) = patch else {
1466        operations.push(PatchEntityOperation {
1467            op: PatchEntityOperationType::Set,
1468            path,
1469            value: Some(patch.clone()),
1470        });
1471        return;
1472    };
1473
1474    for (key, value) in patch_object {
1475        let mut child_path = path.clone();
1476        child_path.push(key.clone());
1477        match value {
1478            JsonValue::Null => operations.push(PatchEntityOperation {
1479                op: PatchEntityOperationType::Unset,
1480                path: child_path,
1481                value: None,
1482            }),
1483            JsonValue::Object(object) if object.is_empty() => {
1484                let child_target = match target {
1485                    JsonValue::Object(target_object) => target_object.get(key),
1486                    _ => None,
1487                };
1488                if !matches!(child_target, Some(JsonValue::Object(_))) {
1489                    operations.push(PatchEntityOperation {
1490                        op: PatchEntityOperationType::Set,
1491                        path: child_path,
1492                        value: Some(JsonValue::Object(Default::default())),
1493                    });
1494                }
1495            }
1496            JsonValue::Object(_) => {
1497                let child_target = match target {
1498                    JsonValue::Object(target_object) => target_object.get(key),
1499                    _ => None,
1500                }
1501                .unwrap_or(&JsonValue::Null);
1502                collect_document_json_merge_patch_operations(
1503                    child_target,
1504                    child_path,
1505                    value,
1506                    operations,
1507                );
1508            }
1509            _ => operations.push(PatchEntityOperation {
1510                op: PatchEntityOperationType::Set,
1511                path: child_path,
1512                value: Some(value.clone()),
1513            }),
1514        }
1515    }
1516}
1517
1518impl RedDBRuntime {
1519    pub(crate) fn apply_loaded_patch_entity_core(
1520        &self,
1521        collection: String,
1522        mut entity: crate::storage::UnifiedEntity,
1523        payload: JsonValue,
1524        operations: Vec<PatchEntityOperation>,
1525    ) -> RedDBResult<AppliedEntityMutation> {
1526        let id = entity.id;
1527        let previous_xmax = entity.xmax;
1528        let operations = normalize_ttl_patch_operations(operations)?;
1529        // Snapshot pre-patch row fields for the secondary-index hook —
1530        // empty for non-row entities, which is the desired no-op.
1531        let pre_mutation_fields = entity_row_fields_snapshot(&entity);
1532
1533        // Versioned collections retain MVCC history: a PATCH/UPDATE must
1534        // produce a NEW full version (the merged document/row/node) with
1535        // the prior version tombstoned, mirroring the SQL UPDATE row
1536        // path. Gated STRICTLY on the collection `versioned` flag —
1537        // non-versioned collections keep last-writer-wins in-place
1538        // mutation. Row entities (documents / table rows / KV) carry
1539        // logical-id MVCC history (Phase 1/2); graph nodes & edges join
1540        // in Phase 3. The post-image re-stamp (new physical id, same
1541        // logical_id, fresh xmin) at the tail of this function is fully
1542        // kind-agnostic, so the same machinery installs a versioned
1543        // graph-node update.
1544        let patch_versions = matches!(
1545            entity.data,
1546            crate::storage::EntityData::Row(_)
1547                | crate::storage::EntityData::Node(_)
1548                | crate::storage::EntityData::Edge(_)
1549        ) && self.vcs_is_versioned(&collection).unwrap_or(false);
1550        let versioned_update_xid = if patch_versions {
1551            match self.current_xid() {
1552                Some(xid) => Some(xid),
1553                None => {
1554                    let snapshot_manager = self.snapshot_manager();
1555                    let xid = snapshot_manager.begin();
1556                    snapshot_manager.commit(xid);
1557                    Some(xid)
1558                }
1559            }
1560        } else {
1561            None
1562        };
1563        let mut replaced_entity = versioned_update_xid.map(|xid| {
1564            let mut old = entity.clone();
1565            old.set_xmax(xid);
1566            old
1567        });
1568
1569        let db = self.db();
1570        let store = db.store();
1571        let Some(manager) = store.get_collection(&collection) else {
1572            return Err(crate::RedDBError::NotFound(format!(
1573                "collection not found: {collection}"
1574            )));
1575        };
1576
1577        let mut patch_metadata: Option<crate::storage::unified::Metadata> = None;
1578        let mut metadata_changed = false;
1579        let mut modified_columns: Vec<String> = Vec::new();
1580        let mut context_index_dirty = false;
1581        let mut graph_node_type: Option<String> = None;
1582        let mut graph_edge_weight: Option<f32> = None;
1583
1584        let row_contract_timestamps = db
1585            .collection_contract(&collection)
1586            .map(|c| c.timestamps_enabled)
1587            .unwrap_or(false);
1588
1589        match &mut entity.data {
1590            crate::storage::EntityData::Row(row) => {
1591                let is_document_collection = db
1592                    .collection_contract(&collection)
1593                    .map(|contract| {
1594                        contract.declared_model == crate::catalog::CollectionModel::Document
1595                    })
1596                    .unwrap_or(false);
1597                let binary_body = self.binary_document_body_enabled();
1598                let mut field_ops = Vec::new();
1599                let mut metadata_ops = Vec::new();
1600                let mut document_body_ops = Vec::new();
1601                let mut document_body_replace: Option<JsonValue> = None;
1602                let has_patch_operations = !operations.is_empty();
1603
1604                for mut op in operations {
1605                    let Some(root) = op.path.first().map(String::as_str) else {
1606                        return Err(crate::RedDBError::Query(
1607                            "patch path cannot be empty".to_string(),
1608                        ));
1609                    };
1610
1611                    match root {
1612                        "body" if is_document_collection => {
1613                            if op.path.len() == 1 {
1614                                match op.op {
1615                                    PatchEntityOperationType::Set
1616                                    | PatchEntityOperationType::Replace => {
1617                                        let Some(value) = op.value.take() else {
1618                                            return Err(crate::RedDBError::Query(
1619                                                "document body replacement requires a value"
1620                                                    .to_string(),
1621                                            ));
1622                                        };
1623                                        document_body_replace = Some(value);
1624                                    }
1625                                    PatchEntityOperationType::Unset => {
1626                                        return Err(crate::RedDBError::Query(
1627                                            "document body cannot be unset; replace it instead"
1628                                                .to_string(),
1629                                        ));
1630                                    }
1631                                }
1632                                continue;
1633                            }
1634                            if op.path.len() < 2 {
1635                                return Err(crate::RedDBError::Query(
1636                                    "document body patch paths require a nested key; use payload.body for full replacement"
1637                                        .to_string(),
1638                                ));
1639                            }
1640                            op.path.remove(0);
1641                            reject_document_array_position_path(&op.path)?;
1642                            document_body_ops.push(op);
1643                        }
1644                        "fields" | "named" => {
1645                            if op.path.len() < 2 {
1646                                return Err(crate::RedDBError::Query(
1647                                    "patch path 'fields' requires a nested key".to_string(),
1648                                ));
1649                            }
1650                            if row_contract_timestamps {
1651                                let leaf = op.path.get(1).map(String::as_str);
1652                                if matches!(leaf, Some("created_at") | Some("updated_at")) {
1653                                    return Err(crate::RedDBError::Query(format!(
1654                                        "collection '{}' manages '{}' automatically — do not set it in UPDATE",
1655                                        collection,
1656                                        leaf.unwrap_or("")
1657                                    )));
1658                                }
1659                            }
1660                            op.path.remove(0);
1661                            field_ops.push(op);
1662                        }
1663                        "metadata" => {
1664                            if op.path.len() < 2 {
1665                                return Err(crate::RedDBError::Query(
1666                                    "patch path 'metadata' requires a nested key".to_string(),
1667                                ));
1668                            }
1669                            op.path.remove(0);
1670                            metadata_ops.push(op);
1671                        }
1672                        _ => {
1673                            return Err(crate::RedDBError::Query(format!(
1674                                "unsupported patch target '{root}' for table rows. Use fields/*, metadata/*, or weight"
1675                            )));
1676                        }
1677                    }
1678                }
1679
1680                if document_body_replace.is_some() || !document_body_ops.is_empty() {
1681                    context_index_dirty = true;
1682                    let mut body = match document_body_replace {
1683                        Some(body) => body,
1684                        None => document_body_from_named(
1685                            row.named.get_or_insert_with(Default::default),
1686                        )?,
1687                    };
1688                    apply_document_body_patch_operations(&mut body, &document_body_ops)?;
1689                    replace_document_row_body(
1690                        row,
1691                        body,
1692                        &collection,
1693                        binary_body,
1694                        &mut modified_columns,
1695                    )?;
1696                }
1697
1698                if is_document_collection && !has_patch_operations {
1699                    let mut body =
1700                        document_body_from_named(row.named.get_or_insert_with(Default::default))?;
1701                    let previous_body = body.clone();
1702                    apply_document_json_merge_patch(
1703                        &mut body,
1704                        document_merge_patch_payload(&payload),
1705                    )?;
1706                    if body != previous_body {
1707                        context_index_dirty = true;
1708                        replace_document_row_body(
1709                            row,
1710                            body,
1711                            &collection,
1712                            binary_body,
1713                            &mut modified_columns,
1714                        )?;
1715                    }
1716                }
1717
1718                if !field_ops.is_empty() {
1719                    context_index_dirty = true;
1720                    let named = row.named.get_or_insert_with(Default::default);
1721                    if is_document_collection {
1722                        // Document fields are projections of the canonical
1723                        // `body` JSON. Apply the assignment to the body so the
1724                        // stored document remains single-source.
1725                        let mut body = document_body_from_named(named)?;
1726                        apply_patch_operations_to_json(&mut body, &field_ops)
1727                            .map_err(crate::RedDBError::Query)?;
1728                        replace_document_row_body(
1729                            row,
1730                            body,
1731                            &collection,
1732                            binary_body,
1733                            &mut modified_columns,
1734                        )?;
1735                    } else {
1736                        for op in &field_ops {
1737                            if let Some(col) = op.path.first() {
1738                                modified_columns.push(col.clone());
1739                            }
1740                        }
1741                        apply_patch_operations_to_storage_map(named, &field_ops)?;
1742                    }
1743                }
1744
1745                if let Some(fields) = payload
1746                    .get("fields")
1747                    .and_then(crate::json::Value::as_object)
1748                {
1749                    if row_contract_timestamps {
1750                        for key in fields.keys() {
1751                            if key == "created_at" || key == "updated_at" {
1752                                return Err(crate::RedDBError::Query(format!(
1753                                    "collection '{}' manages '{}' automatically — do not set it in UPDATE",
1754                                    collection, key
1755                                )));
1756                            }
1757                        }
1758                    }
1759                    context_index_dirty = true;
1760                    let named = row.named.get_or_insert_with(Default::default);
1761                    if is_document_collection {
1762                        // Mirror the `field_ops` path above: keep the canonical
1763                        // `body` JSON as the single stored representation.
1764                        let mut body = document_body_from_named(named)?;
1765                        if let JsonValue::Object(map) = &mut body {
1766                            for (key, value) in fields {
1767                                map.insert(key.clone(), value.clone());
1768                            }
1769                        }
1770                        replace_document_row_body(
1771                            row,
1772                            body,
1773                            &collection,
1774                            binary_body,
1775                            &mut modified_columns,
1776                        )?;
1777                    } else {
1778                        for (key, value) in fields {
1779                            modified_columns.push(key.clone());
1780                            named.insert(key.clone(), json_to_storage_value(value)?);
1781                        }
1782                    }
1783                }
1784
1785                if !metadata_ops.is_empty() {
1786                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
1787                    let metadata = patch_metadata.get_or_insert_with(|| {
1788                        store.get_metadata(&collection, id).unwrap_or_default()
1789                    });
1790                    let mut metadata_json = metadata_to_json(metadata);
1791                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
1792                        .map_err(crate::RedDBError::Query)?;
1793                    *metadata = metadata_from_json(&metadata_json)?;
1794                    metadata_changed = true;
1795                }
1796
1797                if !modified_columns.is_empty() || row_contract_timestamps {
1798                    let contract = CollectionContractWriteEnforcer::new(&db, &collection);
1799                    let current_fields = if let Some(named) = row.named.take() {
1800                        named.into_iter().collect::<Vec<_>>()
1801                    } else if let Some(schema) = row.schema.as_ref() {
1802                        schema
1803                            .iter()
1804                            .cloned()
1805                            .zip(row.columns.iter().cloned())
1806                            .collect::<Vec<_>>()
1807                    } else {
1808                        Vec::new()
1809                    };
1810                    let normalized_fields = contract.normalize_update_fields(current_fields)?;
1811                    if row_contract_timestamps {
1812                        modified_columns.push("updated_at".to_string());
1813                        context_index_dirty = true;
1814                    }
1815                    if contract.requires_uniqueness_check(&modified_columns) {
1816                        contract.enforce_row_uniqueness(&normalized_fields, Some(id))?;
1817                    }
1818                    row.named = Some(normalized_fields.into_iter().collect());
1819                }
1820            }
1821            crate::storage::EntityData::Node(node) => {
1822                let mut field_ops = Vec::new();
1823                let mut metadata_ops = Vec::new();
1824                let mut node_type_ops = Vec::new();
1825
1826                for mut op in operations {
1827                    let Some(root) = op.path.first().map(String::as_str) else {
1828                        return Err(crate::RedDBError::Query(
1829                            "patch path cannot be empty".to_string(),
1830                        ));
1831                    };
1832
1833                    match root {
1834                        "fields" | "properties" => {
1835                            if op.path.len() < 2 {
1836                                return Err(crate::RedDBError::Query(
1837                                    "patch path 'fields' requires a nested key".to_string(),
1838                                ));
1839                            }
1840                            op.path.remove(0);
1841                            field_ops.push(op);
1842                        }
1843                        "metadata" => {
1844                            if op.path.len() < 2 {
1845                                return Err(crate::RedDBError::Query(
1846                                    "patch path 'metadata' requires a nested key".to_string(),
1847                                ));
1848                            }
1849                            op.path.remove(0);
1850                            metadata_ops.push(op);
1851                        }
1852                        "node_type" => {
1853                            if op.path.len() != 1 {
1854                                return Err(crate::RedDBError::Query(
1855                                    "patch path 'node_type' does not allow nested keys".to_string(),
1856                                ));
1857                            }
1858                            op.path.clear();
1859                            node_type_ops.push(op);
1860                        }
1861                        _ => {
1862                            return Err(crate::RedDBError::Query(format!(
1863                                "unsupported patch target '{root}' for graph nodes. Use fields/*, properties/*, node_type, or metadata/*"
1864                            )));
1865                        }
1866                    }
1867                }
1868
1869                for op in node_type_ops {
1870                    context_index_dirty = true;
1871                    let value = op.value.ok_or_else(|| {
1872                        crate::RedDBError::Query("node_type operations require a value".to_string())
1873                    })?;
1874
1875                    match op.op {
1876                        PatchEntityOperationType::Unset => {
1877                            return Err(crate::RedDBError::Query(
1878                                "node_type cannot be unset through patch operations".to_string(),
1879                            ));
1880                        }
1881                        PatchEntityOperationType::Set | PatchEntityOperationType::Replace => {
1882                            let Some(node_type) = value.as_str() else {
1883                                return Err(crate::RedDBError::Query(
1884                                    "node_type operation requires a text value".to_string(),
1885                                ));
1886                            };
1887                            graph_node_type = Some(node_type.to_string());
1888                            modified_columns.push("node_type".to_string());
1889                        }
1890                    }
1891                }
1892
1893                if !field_ops.is_empty() {
1894                    context_index_dirty = true;
1895                    apply_patch_operations_to_storage_map(&mut node.properties, &field_ops)?;
1896                }
1897
1898                if let Some(fields) = payload
1899                    .get("fields")
1900                    .and_then(crate::json::Value::as_object)
1901                {
1902                    context_index_dirty = true;
1903                    for (key, value) in fields {
1904                        node.properties
1905                            .insert(key.clone(), json_to_storage_value(value)?);
1906                    }
1907                }
1908
1909                if !metadata_ops.is_empty() {
1910                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
1911                    let metadata = patch_metadata.get_or_insert_with(|| {
1912                        store.get_metadata(&collection, id).unwrap_or_default()
1913                    });
1914                    let mut metadata_json = metadata_to_json(metadata);
1915                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
1916                        .map_err(crate::RedDBError::Query)?;
1917                    *metadata = metadata_from_json(&metadata_json)?;
1918                    metadata_changed = true;
1919                }
1920            }
1921            crate::storage::EntityData::Edge(edge) => {
1922                let mut field_ops = Vec::new();
1923                let mut metadata_ops = Vec::new();
1924                let mut weight_ops = Vec::new();
1925
1926                for mut op in operations {
1927                    let Some(root) = op.path.first().map(String::as_str) else {
1928                        return Err(crate::RedDBError::Query(
1929                            "patch path cannot be empty".to_string(),
1930                        ));
1931                    };
1932
1933                    match root {
1934                        "fields" | "properties" => {
1935                            if op.path.len() < 2 {
1936                                return Err(crate::RedDBError::Query(
1937                                    "patch path 'fields' requires a nested key".to_string(),
1938                                ));
1939                            }
1940                            op.path.remove(0);
1941                            field_ops.push(op);
1942                        }
1943                        "weight" => {
1944                            if op.path.len() != 1 {
1945                                return Err(crate::RedDBError::Query(
1946                                    "patch path 'weight' does not allow nested keys".to_string(),
1947                                ));
1948                            }
1949                            op.path.clear();
1950                            weight_ops.push(op);
1951                        }
1952                        "metadata" => {
1953                            if op.path.len() < 2 {
1954                                return Err(crate::RedDBError::Query(
1955                                    "patch path 'metadata' requires a nested key".to_string(),
1956                                ));
1957                            }
1958                            op.path.remove(0);
1959                            metadata_ops.push(op);
1960                        }
1961                        _ => {
1962                            return Err(crate::RedDBError::Query(format!(
1963                                "unsupported patch target '{root}' for graph edges. Use fields/*, weight, metadata/*"
1964                            )));
1965                        }
1966                    }
1967                }
1968
1969                if !field_ops.is_empty() {
1970                    context_index_dirty = true;
1971                    apply_patch_operations_to_storage_map(&mut edge.properties, &field_ops)?;
1972                }
1973
1974                for op in weight_ops {
1975                    context_index_dirty = true;
1976                    let value = op.value.ok_or_else(|| {
1977                        crate::RedDBError::Query("weight operations require a value".to_string())
1978                    })?;
1979
1980                    match op.op {
1981                        PatchEntityOperationType::Unset => {
1982                            return Err(crate::RedDBError::Query(
1983                                "weight cannot be unset through patch operations".to_string(),
1984                            ));
1985                        }
1986                        PatchEntityOperationType::Set | PatchEntityOperationType::Replace => {
1987                            let Some(weight) = value.as_f64() else {
1988                                return Err(crate::RedDBError::Query(
1989                                    "weight operation requires a numeric value".to_string(),
1990                                ));
1991                            };
1992                            graph_edge_weight = Some(weight as f32);
1993                            edge.weight = weight as f32;
1994                            modified_columns.push("weight".to_string());
1995                        }
1996                    }
1997                }
1998
1999                if let Some(fields) = payload
2000                    .get("fields")
2001                    .and_then(crate::json::Value::as_object)
2002                {
2003                    context_index_dirty = true;
2004                    for (key, value) in fields {
2005                        edge.properties
2006                            .insert(key.clone(), json_to_storage_value(value)?);
2007                    }
2008                }
2009
2010                if !metadata_ops.is_empty() {
2011                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
2012                    let metadata = patch_metadata.get_or_insert_with(|| {
2013                        store.get_metadata(&collection, id).unwrap_or_default()
2014                    });
2015                    let mut metadata_json = metadata_to_json(metadata);
2016                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
2017                        .map_err(crate::RedDBError::Query)?;
2018                    *metadata = metadata_from_json(&metadata_json)?;
2019                    metadata_changed = true;
2020                }
2021            }
2022            crate::storage::EntityData::Vector(vector) => {
2023                let mut field_ops = Vec::new();
2024                let mut metadata_ops = Vec::new();
2025
2026                for mut op in operations {
2027                    let Some(root) = op.path.first().map(String::as_str) else {
2028                        return Err(crate::RedDBError::Query(
2029                            "patch path cannot be empty".to_string(),
2030                        ));
2031                    };
2032
2033                    match root {
2034                        "fields" => {
2035                            if op.path.len() < 2 {
2036                                return Err(crate::RedDBError::Query(
2037                                    "patch path 'fields' requires a nested key".to_string(),
2038                                ));
2039                            }
2040                            op.path.remove(0);
2041                            let Some(target) = op.path.first().map(String::as_str) else {
2042                                return Err(crate::RedDBError::Query(
2043                                    "patch path requires a target under fields".to_string(),
2044                                ));
2045                            };
2046                            if !matches!(target, "dense" | "content" | "sparse") {
2047                                return Err(crate::RedDBError::Query(format!(
2048                                    "unsupported vector patch target '{target}'"
2049                                )));
2050                            }
2051                            field_ops.push(op);
2052                        }
2053                        "metadata" => {
2054                            if op.path.len() < 2 {
2055                                return Err(crate::RedDBError::Query(
2056                                    "patch path 'metadata' requires a nested key".to_string(),
2057                                ));
2058                            }
2059                            op.path.remove(0);
2060                            metadata_ops.push(op);
2061                        }
2062                        _ => {
2063                            return Err(crate::RedDBError::Query(format!(
2064                                "unsupported patch target '{root}' for vectors. Use fields/* or metadata/*"
2065                            )));
2066                        }
2067                    }
2068                }
2069
2070                if !field_ops.is_empty() {
2071                    context_index_dirty = true;
2072                    apply_patch_operations_to_vector_fields(vector, &field_ops)?;
2073                }
2074
2075                if let Some(fields) = payload
2076                    .get("fields")
2077                    .and_then(crate::json::Value::as_object)
2078                {
2079                    context_index_dirty = true;
2080                    if let Some(content) =
2081                        fields.get("content").and_then(crate::json::Value::as_str)
2082                    {
2083                        vector.content = Some(content.to_string());
2084                    }
2085                    if let Some(dense) = fields.get("dense") {
2086                        vector.dense = dense
2087                            .as_array()
2088                            .ok_or_else(|| {
2089                                crate::RedDBError::Query(
2090                                    "field 'dense' must be an array".to_string(),
2091                                )
2092                            })?
2093                            .iter()
2094                            .map(|value| {
2095                                value.as_f64().map(|value| value as f32).ok_or_else(|| {
2096                                    crate::RedDBError::Query(
2097                                        "field 'dense' must contain only numbers".to_string(),
2098                                    )
2099                                })
2100                            })
2101                            .collect::<Result<Vec<_>, _>>()?;
2102                    }
2103                }
2104
2105                if !metadata_ops.is_empty() {
2106                    ensure_non_tree_reserved_metadata_patch_paths(&metadata_ops)?;
2107                    let metadata = patch_metadata.get_or_insert_with(|| {
2108                        store.get_metadata(&collection, id).unwrap_or_default()
2109                    });
2110                    let mut metadata_json = metadata_to_json(metadata);
2111                    apply_patch_operations_to_json(&mut metadata_json, &metadata_ops)
2112                        .map_err(crate::RedDBError::Query)?;
2113                    *metadata = metadata_from_json(&metadata_json)?;
2114                    metadata_changed = true;
2115                }
2116            }
2117            crate::storage::EntityData::TimeSeries(_)
2118            | crate::storage::EntityData::QueueMessage(_) => {
2119                return Err(crate::RedDBError::Query(
2120                    "patch operations are not supported for TimeSeries or QueueMessage entities"
2121                        .to_string(),
2122                ));
2123            }
2124        }
2125
2126        if let Some(node_type) = graph_node_type {
2127            if let crate::storage::EntityKind::GraphNode(node) = &mut entity.kind {
2128                node.node_type = node_type;
2129            }
2130        }
2131        if let Some(weight) = graph_edge_weight {
2132            if let crate::storage::EntityKind::GraphEdge(edge) = &mut entity.kind {
2133                edge.weight = (weight * 1000.0) as u32;
2134            }
2135        }
2136
2137        if let Some(metadata) = payload
2138            .get("metadata")
2139            .and_then(crate::json::Value::as_object)
2140        {
2141            let patch_metadata = patch_metadata
2142                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default());
2143            for (key, value) in metadata {
2144                ensure_non_tree_reserved_metadata_key(key)?;
2145                patch_metadata.set(key.clone(), json_to_metadata_value(value)?);
2146            }
2147            metadata_changed = true;
2148        }
2149
2150        for (key, value) in parse_top_level_ttl_metadata_entries(&payload)? {
2151            let patch_metadata = patch_metadata
2152                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default());
2153            if matches!(value, crate::storage::unified::MetadataValue::Null) {
2154                patch_metadata.remove(&key);
2155            } else {
2156                patch_metadata.set(key, value);
2157            }
2158            metadata_changed = true;
2159        }
2160
2161        entity.updated_at = std::time::SystemTime::now()
2162            .duration_since(std::time::UNIX_EPOCH)
2163            .unwrap_or_default()
2164            .as_secs();
2165
2166        // Versioned PATCH: re-stamp the merged post-image as a fresh
2167        // physical version keyed on the same logical_id, and close the
2168        // prior version's xmax at the same xid. `install_versioned_*`
2169        // in persist installs the new row and tombstones the old one;
2170        // `record_pending_versioned_update` arms first-committer-wins.
2171        if let Some(xid) = versioned_update_xid {
2172            let logical_id = entity.logical_id();
2173            entity.id = store.next_entity_id();
2174            entity.set_logical_id(logical_id);
2175            entity.set_xmin(xid);
2176            entity.set_xmax(0);
2177            if let Some(old) = replaced_entity.as_mut() {
2178                old.set_xmax(xid);
2179            }
2180        }
2181
2182        modified_columns = dedupe_modified_columns(modified_columns);
2183
2184        Ok(AppliedEntityMutation {
2185            id: entity.id,
2186            collection,
2187            entity,
2188            metadata: patch_metadata,
2189            modified_columns,
2190            persist_metadata: metadata_changed,
2191            context_index_dirty,
2192            replaced_entity,
2193            replaced_entity_previous_xmax: previous_xmax,
2194            pre_mutation_fields,
2195        })
2196    }
2197
2198    pub(crate) fn apply_loaded_sql_update_row_core(
2199        &self,
2200        collection: String,
2201        mut entity: crate::storage::UnifiedEntity,
2202        static_field_assignments: &[(String, Value)],
2203        dynamic_field_assignments: Vec<(String, Value)>,
2204        static_metadata_assignments: &[(String, MetadataValue)],
2205        dynamic_metadata_assignments: Vec<(String, MetadataValue)>,
2206        row_contract_plan: Option<&RowUpdateContractPlan>,
2207        row_modified_columns_template: &[String],
2208        row_touches_unique_columns: bool,
2209    ) -> RedDBResult<AppliedEntityMutation> {
2210        let id = entity.id;
2211        let previous_xmax = entity.xmax;
2212        let db = self.db();
2213        let store = db.store();
2214        let Some(_) = store.get_collection(&collection) else {
2215            return Err(crate::RedDBError::NotFound(format!(
2216                "collection not found: {collection}"
2217            )));
2218        };
2219
2220        let versioned_update_xid = match self.current_xid() {
2221            Some(xid) => Some(xid),
2222            None => {
2223                let snapshot_manager = self.snapshot_manager();
2224                let xid = snapshot_manager.begin();
2225                snapshot_manager.commit(xid);
2226                Some(xid)
2227            }
2228        };
2229        let mut replaced_entity = versioned_update_xid.map(|xid| {
2230            let mut old = entity.clone();
2231            old.set_xmax(xid);
2232            old
2233        });
2234
2235        let mut patch_metadata: Option<crate::storage::unified::Metadata> = None;
2236        let row_contract_timestamps = row_contract_plan
2237            .map(|plan| plan.timestamps_enabled)
2238            .unwrap_or(false);
2239        let mut metadata_changed = false;
2240        let mut modified_columns = row_modified_columns_template.to_vec();
2241        let mut context_index_dirty = !modified_columns.is_empty();
2242
2243        // Snapshot OLD field values BEFORE applying the assignments —
2244        // the secondary-index maintenance hook needs both before/after to
2245        // delete-then-insert under changed indexed columns.
2246        let pre_mutation_fields = entity_row_fields_snapshot(&entity);
2247
2248        let crate::storage::EntityData::Row(row) = &mut entity.data else {
2249            return Err(crate::RedDBError::Query(
2250                "SQL row update fast path requires a row entity".to_string(),
2251            ));
2252        };
2253
2254        let _ = row_contract_plan;
2255
2256        // Documents store the full JSON under the `body` column. Legacy rows
2257        // also promote top-level keys for filtering, while binary body mode
2258        // keeps `body` as the single source of truth. Reflect non-body SET
2259        // assignments into `body` as patch operations so dotted paths update
2260        // nested JSON instead of becoming literal top-level keys.
2261        let is_document_collection = db
2262            .collection_contract(&collection)
2263            .map(|contract| contract.declared_model == crate::catalog::CollectionModel::Document)
2264            .unwrap_or(false);
2265        let kv_key = if row.get_field("value").is_some() || row.columns.len() >= 2 {
2266            row.get_field("key")
2267                .cloned()
2268                .or_else(|| row.columns.first().cloned())
2269        } else {
2270            None
2271        };
2272        if is_document_collection {
2273            // #1366: when a SET assigns to `body`, treat it as a FULL REPLACE of
2274            // the stored document — drop other column assignments, the new body
2275            // is the single source of truth.
2276            let body_assignment = static_field_assignments
2277                .iter()
2278                .chain(dynamic_field_assignments.iter())
2279                .rev()
2280                .find(|(column, _)| column == "body")
2281                .cloned();
2282
2283            match body_assignment {
2284                Some((_, value)) => {
2285                    let body = document_body_from_assignment(&value)?;
2286                    replace_document_row_body(
2287                        row,
2288                        body,
2289                        &collection,
2290                        self.binary_document_body_enabled(),
2291                        &mut modified_columns,
2292                    )?;
2293                    context_index_dirty = true;
2294                }
2295                None => {
2296                    // #1365: no `body` assignment — fall back to nested-path
2297                    // partial update via document_body_set_operation.
2298                    let mut static_row_assignments = Vec::new();
2299                    let mut dynamic_row_assignments = Vec::new();
2300                    let mut document_body_ops = Vec::new();
2301                    for (column, value) in static_field_assignments.iter().cloned() {
2302                        if column == "body" {
2303                            static_row_assignments.push((column, value));
2304                        } else {
2305                            document_body_ops.push(document_body_set_operation(&column, value));
2306                        }
2307                    }
2308                    for (column, value) in dynamic_field_assignments {
2309                        if column == "body" {
2310                            dynamic_row_assignments.push((column, value));
2311                        } else {
2312                            document_body_ops.push(document_body_set_operation(&column, value));
2313                        }
2314                    }
2315                    apply_row_field_assignments_raw(row, static_row_assignments);
2316                    apply_row_field_assignments_raw(row, dynamic_row_assignments);
2317                    if !document_body_ops.is_empty() {
2318                        let mut body = document_body_from_named(
2319                            row.named.get_or_insert_with(Default::default),
2320                        )?;
2321                        apply_document_body_patch_operations(&mut body, &document_body_ops)?;
2322                        replace_document_row_body(
2323                            row,
2324                            body,
2325                            &collection,
2326                            self.binary_document_body_enabled(),
2327                            &mut modified_columns,
2328                        )?;
2329                        context_index_dirty = true;
2330                    }
2331                }
2332            }
2333        } else {
2334            // Non-document collection: every assignment is a regular row column.
2335            let mut all_assignments: Vec<(String, Value)> = static_field_assignments.to_vec();
2336            all_assignments.extend(dynamic_field_assignments);
2337            apply_row_field_assignments_raw(row, all_assignments);
2338            if let Some(key) = kv_key {
2339                set_row_field(row, "key", key);
2340            }
2341        }
2342
2343        for (key, value) in static_metadata_assignments
2344            .iter()
2345            .cloned()
2346            .chain(dynamic_metadata_assignments)
2347        {
2348            ensure_non_tree_reserved_metadata_key(&key)?;
2349            patch_metadata
2350                .get_or_insert_with(|| store.get_metadata(&collection, id).unwrap_or_default())
2351                .set(key, value);
2352            metadata_changed = true;
2353        }
2354
2355        if !modified_columns.is_empty() || row_contract_timestamps {
2356            let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2357            if row_contract_timestamps {
2358                context_index_dirty = true;
2359                set_row_field(
2360                    row,
2361                    "updated_at",
2362                    Value::UnsignedInteger(current_unix_ms_u64()),
2363                );
2364                modified_columns.push("updated_at".to_string());
2365            }
2366            if row_touches_unique_columns {
2367                let current_fields = collect_row_fields(row);
2368                contract.enforce_row_uniqueness(&current_fields, Some(id))?;
2369            }
2370        }
2371
2372        entity.updated_at = std::time::SystemTime::now()
2373            .duration_since(std::time::UNIX_EPOCH)
2374            .unwrap_or_default()
2375            .as_secs();
2376
2377        if let Some(xid) = versioned_update_xid {
2378            let logical_id = entity.logical_id();
2379            entity.id = store.next_entity_id();
2380            entity.set_logical_id(logical_id);
2381            entity.set_xmin(xid);
2382            entity.set_xmax(0);
2383            if let Some(old) = replaced_entity.as_mut() {
2384                old.set_xmax(xid);
2385            }
2386        }
2387
2388        modified_columns = dedupe_modified_columns(modified_columns);
2389
2390        Ok(AppliedEntityMutation {
2391            id: entity.id,
2392            collection,
2393            entity,
2394            metadata: patch_metadata,
2395            modified_columns,
2396            persist_metadata: metadata_changed,
2397            context_index_dirty,
2398            replaced_entity,
2399            replaced_entity_previous_xmax: previous_xmax,
2400            pre_mutation_fields,
2401        })
2402    }
2403
2404    pub(crate) fn persist_applied_entity_mutations(
2405        &self,
2406        applied: &[AppliedEntityMutation],
2407    ) -> RedDBResult<()> {
2408        if applied.is_empty() {
2409            return Ok(());
2410        }
2411
2412        let store = self.db().store();
2413        let collection = &applied[0].collection;
2414        let Some(manager) = store.get_collection(collection) else {
2415            return Err(crate::RedDBError::NotFound(format!(
2416                "collection not found: {collection}"
2417            )));
2418        };
2419
2420        let mut ordinary = Vec::with_capacity(applied.len());
2421        for item in applied {
2422            if let Some(old_version) = item.replaced_entity.as_ref() {
2423                store
2424                    .install_versioned_table_row_update(
2425                        collection,
2426                        old_version.clone(),
2427                        item.entity.clone(),
2428                        if item.persist_metadata {
2429                            item.metadata.as_ref()
2430                        } else {
2431                            None
2432                        },
2433                    )
2434                    .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
2435                if self.current_xid().is_some() {
2436                    self.record_pending_versioned_update(
2437                        crate::runtime::impl_core::current_connection_id(),
2438                        collection,
2439                        old_version.id,
2440                        item.entity.id,
2441                        old_version.xmax,
2442                        item.replaced_entity_previous_xmax,
2443                    );
2444                }
2445            } else {
2446                ordinary.push(item);
2447            }
2448        }
2449        if ordinary.is_empty() {
2450            return Ok(());
2451        }
2452
2453        manager
2454            .update_hot_batch_with_metadata(ordinary.iter().map(|item| {
2455                (
2456                    &item.entity,
2457                    item.modified_columns.as_slice(),
2458                    if item.persist_metadata {
2459                        item.metadata.as_ref()
2460                    } else {
2461                        None
2462                    },
2463                )
2464            }))
2465            .map_err(|err| crate::RedDBError::Query(err.to_string()))?;
2466
2467        // PG-HOT-like fast path: segment in-place is done; when no
2468        // mutation touches a secondary-indexed column AND no metadata
2469        // payload needs to be folded into a B-tree record, skip the
2470        // in-line B-tree upsert. The WAL still records the update
2471        // (durability preserved; recovery replay rebuilds the B-tree),
2472        // and `manager.get()` prefers the live segment over the
2473        // B-tree for reads — so the short-circuit is invisible to
2474        // callers. See `persist_entities_to_pager_wal_only`.
2475        let indexed_cols = self
2476            .index_store_ref()
2477            .indexed_columns_set(collection.as_str());
2478        let all_hot = !indexed_cols.is_empty()
2479            && ordinary.iter().all(|item| {
2480                !item.persist_metadata
2481                    && !item
2482                        .modified_columns
2483                        .iter()
2484                        .any(|c| indexed_cols.contains(c))
2485            })
2486            || indexed_cols.is_empty() && ordinary.iter().all(|item| !item.persist_metadata);
2487
2488        // Pass `&[&UnifiedEntity]` — no per-entity clone. The SQL UPDATE
2489        // inner loop hands us `applied` which already owns the post-image
2490        // entity; all we need for the persist path is a read borrow.
2491        let entity_refs: Vec<&crate::storage::UnifiedEntity> =
2492            ordinary.iter().map(|item| &item.entity).collect();
2493        let persist_fn = if all_hot {
2494            crate::storage::unified::UnifiedStore::persist_entity_refs_to_pager_wal_only
2495        } else {
2496            crate::storage::unified::UnifiedStore::persist_entity_refs_to_pager
2497        };
2498        persist_fn(store.as_ref(), collection, &entity_refs)
2499            .map_err(|err| crate::RedDBError::Internal(err.to_string()))
2500    }
2501
2502    pub(crate) fn flush_applied_entity_mutation(
2503        &self,
2504        applied: &AppliedEntityMutation,
2505    ) -> RedDBResult<()> {
2506        let store = self.db().store();
2507        if applied.context_index_dirty {
2508            store
2509                .context_index()
2510                .index_entity(&applied.collection, &applied.entity);
2511        }
2512        // Secondary-index maintenance for SQL UPDATE / JSON-Patch flows.
2513        // Skip when pre_mutation_fields is empty (entity wasn't a row, or
2514        // didn't carry recoverable column names) — there's nothing to
2515        // delete-then-insert in that case.
2516        //
2517        // Also build the CDC damage vector here so downstream consumers
2518        // see which columns changed without re-diffing.
2519        let mut changed_columns: Option<Vec<String>> = None;
2520        if !applied.pre_mutation_fields.is_empty() {
2521            let post = entity_row_fields_snapshot(&applied.entity);
2522            if !post.is_empty() {
2523                let damage = crate::application::entity::row_damage_vector(
2524                    &applied.pre_mutation_fields,
2525                    &post,
2526                );
2527                if !damage.is_empty() {
2528                    changed_columns = Some(
2529                        damage
2530                            .touched_columns()
2531                            .into_iter()
2532                            .map(str::to_string)
2533                            .collect(),
2534                    );
2535                }
2536
2537                // HOT-like fast path (P3.T2/T3): when no modified
2538                // column is covered by a secondary index, skip the
2539                // `index_entity_update` call entirely. The function
2540                // would short-circuit internally, but the call still
2541                // reads the registry lock + walks the damage vector
2542                // — avoiding it saves a few microseconds per UPDATE.
2543                // Page-local replace + t_ctid chain support (true
2544                // HOT) lives in a follow-up storage spec.
2545                // Use the parent-expanded set so that dot-path indexes
2546                // (e.g. "body.service.tier") are triggered when the root
2547                // field ("body") appears in modified_columns.
2548                let indexed_cols: std::collections::HashSet<String> = self
2549                    .index_store_ref()
2550                    .indexed_columns_set_with_parents(applied.collection.as_str());
2551                // Single-source documents keep promoted index columns in the
2552                // body — surface them so the index sees the value move (a no-op
2553                // for ordinary stored columns).
2554                let pre_index_fields = self
2555                    .index_store_ref()
2556                    .augment_body_derived_index_fields(&applied.pre_mutation_fields, &indexed_cols);
2557                let post_index_fields = self
2558                    .index_store_ref()
2559                    .augment_body_derived_index_fields(&post, &indexed_cols);
2560                let modified_cols: std::collections::HashSet<String> =
2561                    crate::application::entity::row_damage_vector(
2562                        &pre_index_fields,
2563                        &post_index_fields,
2564                    )
2565                    .touched_columns()
2566                    .into_iter()
2567                    .map(str::to_string)
2568                    .collect();
2569                if let Some(old_version) = applied.replaced_entity.as_ref() {
2570                    let old_index_fields: Vec<(String, Value)> = pre_index_fields
2571                        .iter()
2572                        .filter(|(col, _)| indexed_cols.contains(col))
2573                        .cloned()
2574                        .collect();
2575                    let new_index_fields: Vec<(String, Value)> = post_index_fields
2576                        .iter()
2577                        .filter(|(col, _)| indexed_cols.contains(col))
2578                        .cloned()
2579                        .collect();
2580                    if !old_index_fields.is_empty() {
2581                        self.index_store_ref()
2582                            .index_entity_delete(
2583                                &applied.collection,
2584                                old_version.id,
2585                                &old_index_fields,
2586                            )
2587                            .map_err(crate::RedDBError::Internal)?;
2588                    }
2589                    if !new_index_fields.is_empty() {
2590                        self.index_store_ref()
2591                            .index_entity_insert(
2592                                &applied.collection,
2593                                applied.entity.id,
2594                                &new_index_fields,
2595                            )
2596                            .map_err(crate::RedDBError::Internal)?;
2597                    }
2598                } else {
2599                    let decision = crate::storage::engine::hot_update::decide(
2600                        &crate::storage::engine::hot_update::HotUpdateInputs {
2601                            collection: applied.collection.as_str(),
2602                            indexed_columns: &indexed_cols,
2603                            modified_columns: &modified_cols,
2604                            // The storage layer currently handles fit via
2605                            // the segment abstraction; we bypass the
2606                            // page-size check here.
2607                            new_tuple_size: 0,
2608                            page_free_space: usize::MAX,
2609                        },
2610                    );
2611                    if !decision.can_hot {
2612                        self.index_store_ref()
2613                            .index_entity_update(
2614                                &applied.collection,
2615                                applied.id,
2616                                &pre_index_fields,
2617                                &post_index_fields,
2618                            )
2619                            .map_err(crate::RedDBError::Internal)?;
2620                    } else {
2621                        // F-04: `applied.collection` is tenant-supplied;
2622                        // strip CR/LF/control bytes via the LogField
2623                        // escaper (ADR 0010).
2624                        tracing::debug!(
2625                            collection = %reddb_wire::audit_safe_log_field(&applied.collection),
2626                            "hot_update fast-path: skipped index_entity_update"
2627                        );
2628                    }
2629                }
2630            }
2631        }
2632        self.cdc_emit_prebuilt_with_columns(
2633            crate::replication::cdc::ChangeOperation::Update,
2634            &applied.collection,
2635            &applied.entity,
2636            "entity",
2637            applied.metadata.as_ref(),
2638            true,
2639            changed_columns,
2640        );
2641        Ok(())
2642    }
2643
2644    pub(crate) fn apply_loaded_patch_entity(
2645        &self,
2646        collection: String,
2647        entity: crate::storage::UnifiedEntity,
2648        payload: JsonValue,
2649        operations: Vec<PatchEntityOperation>,
2650    ) -> RedDBResult<CreateEntityOutput> {
2651        let applied =
2652            self.apply_loaded_patch_entity_core(collection, entity, payload, operations)?;
2653        self.persist_applied_entity_mutations(std::slice::from_ref(&applied))?;
2654        self.flush_applied_entity_mutation(&applied)?;
2655        Ok(CreateEntityOutput {
2656            id: applied.id,
2657            entity: Some(public_document_entity(applied.entity)),
2658        })
2659    }
2660}
2661
2662/// Shape the canonical binary-body document into the public response entity.
2663/// The stored `body` is the single source of truth (RDOC binary container),
2664/// but callers read the returned entity two ways, so the response satisfies
2665/// both: top-level body fields are surfaced into `named` (mirroring the GET
2666/// presentation derive in `named_fields_json`), and `body` itself is decoded
2667/// back to plain JSON. Derivation runs against the binary container first,
2668/// then `body` is replaced with its JSON form. This only reshapes the
2669/// returned copy — persistence already happened against the binary body.
2670fn public_document_entity(
2671    mut entity: crate::storage::UnifiedEntity,
2672) -> crate::storage::UnifiedEntity {
2673    let crate::storage::EntityData::Row(row) = &mut entity.data else {
2674        return entity;
2675    };
2676    let Some(named) = row.named.as_mut() else {
2677        return entity;
2678    };
2679    let Some(Value::Json(bytes)) = named.get("body") else {
2680        return entity;
2681    };
2682    let bytes = bytes.clone();
2683    if let Some(fields) = crate::document_body::body_fields(&bytes) {
2684        for (name, value) in fields {
2685            named.entry(name).or_insert(value);
2686        }
2687    }
2688    if let Some(body_json) = crate::document_body::decode_container_to_json(&bytes) {
2689        if let Ok(json_bytes) = crate::json::to_vec(&body_json) {
2690            named.insert("body".to_string(), Value::Json(json_bytes));
2691        }
2692    }
2693    entity
2694}
2695
2696fn ensure_non_tree_reserved_metadata_patch_paths(
2697    operations: &[PatchEntityOperation],
2698) -> RedDBResult<()> {
2699    for operation in operations {
2700        let Some(key) = operation.path.first().map(String::as_str) else {
2701            continue;
2702        };
2703        ensure_non_tree_reserved_metadata_key(key)?;
2704    }
2705    Ok(())
2706}
2707
2708fn ensure_non_tree_reserved_metadata_key(key: &str) -> RedDBResult<()> {
2709    if key.starts_with(TREE_METADATA_PREFIX) {
2710        return Err(crate::RedDBError::Query(format!(
2711            "metadata key '{}' is reserved for managed trees",
2712            key
2713        )));
2714    }
2715    Ok(())
2716}
2717
2718fn ensure_non_tree_reserved_metadata_entries(
2719    metadata: &[(String, MetadataValue)],
2720) -> RedDBResult<()> {
2721    for (key, _) in metadata {
2722        ensure_non_tree_reserved_metadata_key(key)?;
2723    }
2724    Ok(())
2725}
2726
2727fn ensure_non_tree_structural_edge_label(label: &str) -> RedDBResult<()> {
2728    if label.eq_ignore_ascii_case(TREE_CHILD_EDGE_LABEL) {
2729        return Err(crate::RedDBError::Query(format!(
2730            "edge label '{}' is reserved for managed trees",
2731            TREE_CHILD_EDGE_LABEL
2732        )));
2733    }
2734    Ok(())
2735}
2736
2737impl RedDBRuntime {
2738    pub(crate) fn create_node_unchecked(
2739        &self,
2740        input: CreateNodeInput,
2741    ) -> RedDBResult<CreateEntityOutput> {
2742        let db = self.db();
2743        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
2744        contract.ensure_model(crate::catalog::CollectionModel::Graph)?;
2745        let mut metadata = input.metadata;
2746        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
2747        let mut builder = db.node(&input.collection, &input.label);
2748
2749        if let Some(node_type) = input.node_type {
2750            builder = builder.node_type(node_type);
2751        }
2752
2753        for (key, value) in input.properties {
2754            builder = builder.property(key, value);
2755        }
2756
2757        for (key, value) in metadata {
2758            builder = builder.metadata(key, value);
2759        }
2760
2761        for embedding in input.embeddings {
2762            if let Some(model) = embedding.model {
2763                builder = builder.embedding_with_model(embedding.name, embedding.vector, model);
2764            } else {
2765                builder = builder.embedding(embedding.name, embedding.vector);
2766            }
2767        }
2768
2769        for link in input.table_links {
2770            builder = builder.link_to_table(link.key, link.table);
2771        }
2772
2773        for link in input.node_links {
2774            builder = builder.link_to_weighted(link.target, link.edge_label, link.weight);
2775        }
2776
2777        let id = builder.save()?;
2778        // Phase 1.1 MVCC universal: stamp xmin so concurrent snapshots
2779        // don't see this node until the transaction commits.
2780        self.stamp_xmin_if_in_txn(&input.collection, id);
2781        refresh_context_index(&db, &input.collection, id)?;
2782        self.cdc_emit(
2783            crate::replication::cdc::ChangeOperation::Insert,
2784            &input.collection,
2785            id.raw(),
2786            "graph_node",
2787        );
2788        Ok(CreateEntityOutput {
2789            id,
2790            entity: db.store().get(&input.collection, id),
2791        })
2792    }
2793
2794    pub(crate) fn create_edge_unchecked(
2795        &self,
2796        input: CreateEdgeInput,
2797    ) -> RedDBResult<CreateEntityOutput> {
2798        let db = self.db();
2799        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
2800        contract.ensure_model(crate::catalog::CollectionModel::Graph)?;
2801        let mut metadata = input.metadata;
2802        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
2803        let mut builder = db
2804            .edge(&input.collection, &input.label)
2805            .from(input.from)
2806            .to(input.to);
2807
2808        if let Some(weight) = input.weight {
2809            builder = builder.weight(weight);
2810        }
2811
2812        for (key, value) in input.properties {
2813            builder = builder.property(key, value);
2814        }
2815
2816        for (key, value) in metadata {
2817            builder = builder.metadata(key, value);
2818        }
2819
2820        let id = builder.save()?;
2821        // Phase 1.1 MVCC universal: stamp xmin on the edge so other
2822        // sessions don't follow it until COMMIT.
2823        self.stamp_xmin_if_in_txn(&input.collection, id);
2824        refresh_context_index(&db, &input.collection, id)?;
2825        self.cdc_emit(
2826            crate::replication::cdc::ChangeOperation::Insert,
2827            &input.collection,
2828            id.raw(),
2829            "graph_edge",
2830        );
2831        Ok(CreateEntityOutput {
2832            id,
2833            entity: db.store().get(&input.collection, id),
2834        })
2835    }
2836}
2837
2838fn create_rows_batch_prevalidated_columnar_with_outputs(
2839    runtime: &RedDBRuntime,
2840    collection: String,
2841    column_names: std::sync::Arc<Vec<String>>,
2842    rows: Vec<Vec<crate::storage::schema::Value>>,
2843) -> RedDBResult<Vec<CreateEntityOutput>> {
2844    use crate::storage::{
2845        unified::{EntityData, EntityKind, RowData},
2846        EntityId, UnifiedEntity,
2847    };
2848    use std::sync::Arc;
2849
2850    if rows.is_empty() {
2851        return Ok(Vec::new());
2852    }
2853    runtime.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2854    runtime.check_batch_size(rows.len())?;
2855    runtime.check_db_size()?;
2856
2857    let db = runtime.db();
2858    let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2859    contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2860
2861    let store = db.store();
2862    let table_arc: Arc<str> = Arc::from(collection.as_str());
2863
2864    let indexed_cols = runtime
2865        .index_store_ref()
2866        .indexed_columns_set(collection.as_str());
2867    let has_secondary_indexes = !indexed_cols.is_empty();
2868    let mut field_snapshots: Vec<Vec<(String, crate::storage::schema::Value)>> =
2869        if has_secondary_indexes {
2870            Vec::with_capacity(rows.len())
2871        } else {
2872            Vec::new()
2873        };
2874
2875    let entities: Vec<UnifiedEntity> = rows
2876        .into_iter()
2877        .map(|values| {
2878            if has_secondary_indexes {
2879                let mut snap: Vec<(String, crate::storage::schema::Value)> =
2880                    Vec::with_capacity(indexed_cols.len());
2881                for (name, val) in column_names.iter().zip(values.iter()) {
2882                    if indexed_cols.contains(name) {
2883                        snap.push((name.clone(), val.clone()));
2884                    }
2885                }
2886                field_snapshots.push(snap);
2887            }
2888            let mut row = RowData::new(values);
2889            row.schema = Some(Arc::clone(&column_names));
2890            UnifiedEntity::new(
2891                EntityId::new(0),
2892                EntityKind::TableRow {
2893                    table: Arc::clone(&table_arc),
2894                    row_id: 0,
2895                },
2896                EntityData::Row(row),
2897            )
2898        })
2899        .collect();
2900
2901    let ids = store
2902        .bulk_insert(&collection, entities)
2903        .map_err(|e| crate::RedDBError::Internal(format!("{e:?}")))?;
2904
2905    if has_secondary_indexes {
2906        let index_rows: Vec<(EntityId, Vec<(String, crate::storage::schema::Value)>)> = ids
2907            .iter()
2908            .zip(field_snapshots)
2909            .map(|(id, fields)| (*id, fields))
2910            .collect();
2911        runtime
2912            .index_store_ref()
2913            .index_entity_insert_batch(&collection, &index_rows)
2914            .map_err(crate::RedDBError::Internal)?;
2915    }
2916
2917    runtime.invalidate_result_cache();
2918    runtime.cdc_emit_insert_batch_no_cache_invalidate(&collection, &ids, "table");
2919
2920    Ok(ids
2921        .into_iter()
2922        .map(|id| CreateEntityOutput { id, entity: None })
2923        .collect())
2924}
2925
2926impl RuntimeEntityPort for RedDBRuntime {
2927    fn create_row(&self, input: CreateRowInput) -> RedDBResult<CreateEntityOutput> {
2928        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
2929        let db = self.db();
2930        let CreateRowInput {
2931            collection,
2932            fields,
2933            metadata: input_metadata,
2934            node_links,
2935            vector_links,
2936        } = input;
2937        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2938        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2939        let mut metadata = input_metadata;
2940        apply_collection_default_ttl(&db, &collection, &mut metadata);
2941        let fields = contract.normalize_insert_fields(fields)?;
2942        contract.enforce_row_uniqueness(&fields, None)?;
2943        // Route through MutationEngine for unified hot path.
2944        let engine = self.mutation_engine();
2945        let result = engine.apply(
2946            collection.clone(),
2947            vec![crate::runtime::mutation::MutationRow {
2948                fields,
2949                metadata,
2950                node_links,
2951                vector_links,
2952            }],
2953        )?;
2954        let id = result.ids[0];
2955        // Perf: `db.get(id)` does a *cross-collection* scan (get_any)
2956        // that also takes a write lock on the entity cache. We know
2957        // the collection — hit the manager directly. Cuts
2958        // create_row() p50 roughly in half on the hot path.
2959        Ok(CreateEntityOutput {
2960            id,
2961            entity: db.store().get(&collection, id),
2962        })
2963    }
2964
2965    fn create_rows_batch(
2966        &self,
2967        input: CreateRowsBatchInput,
2968    ) -> RedDBResult<Vec<CreateEntityOutput>> {
2969        if input.rows.is_empty() {
2970            return Ok(Vec::new());
2971        }
2972        self.check_batch_size(input.rows.len())?;
2973        self.check_db_size()?;
2974
2975        let db = self.db();
2976        let collection = input.collection;
2977        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
2978        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
2979
2980        let mut prepared_rows = Vec::with_capacity(input.rows.len());
2981        let mut uniqueness_rows = Vec::with_capacity(input.rows.len());
2982        for row in input.rows {
2983            if row.collection != collection {
2984                return Err(crate::RedDBError::Query(format!(
2985                    "batch row collection mismatch: expected '{}', got '{}'",
2986                    collection, row.collection
2987                )));
2988            }
2989
2990            let mut metadata = row.metadata;
2991            apply_collection_default_ttl(&db, &collection, &mut metadata);
2992            let fields = contract.normalize_insert_fields(row.fields)?;
2993            contract.enforce_row_uniqueness(&fields, None)?;
2994            uniqueness_rows.push(fields.clone());
2995            prepared_rows.push((fields, metadata, row.node_links, row.vector_links));
2996        }
2997
2998        contract.enforce_batch_uniqueness(&uniqueness_rows)?;
2999
3000        // Route through MutationEngine: single bulk_insert + one CDC batch
3001        // instead of N separate cdc_emit() calls (each acquires a write lock).
3002        let engine = {
3003            let e = self.mutation_engine();
3004            if input.suppress_events {
3005                e.with_suppress_events()
3006            } else {
3007                e
3008            }
3009        };
3010        let mutation_rows: Vec<crate::runtime::mutation::MutationRow> = prepared_rows
3011            .into_iter()
3012            .map(|(fields, metadata, node_links, vector_links)| {
3013                crate::runtime::mutation::MutationRow {
3014                    fields,
3015                    metadata,
3016                    node_links,
3017                    vector_links,
3018                }
3019            })
3020            .collect();
3021
3022        let result = engine
3023            .apply(collection.clone(), mutation_rows)
3024            .map_err(|e| crate::RedDBError::Internal(e.to_string()))?;
3025
3026        let store = db.store();
3027        Ok(result
3028            .ids
3029            .into_iter()
3030            .map(|id| CreateEntityOutput {
3031                id,
3032                entity: store.get(&collection, id),
3033            })
3034            .collect())
3035    }
3036
3037    fn create_rows_batch_prevalidated_columnar(
3038        &self,
3039        collection: String,
3040        column_names: std::sync::Arc<Vec<String>>,
3041        rows: Vec<Vec<crate::storage::schema::Value>>,
3042    ) -> RedDBResult<usize> {
3043        create_rows_batch_prevalidated_columnar_with_outputs(self, collection, column_names, rows)
3044            .map(|outputs| outputs.len())
3045    }
3046
3047    fn create_rows_batch_columnar(
3048        &self,
3049        collection: String,
3050        column_names: std::sync::Arc<Vec<String>>,
3051        rows: Vec<Vec<crate::storage::schema::Value>>,
3052    ) -> RedDBResult<usize> {
3053        self.create_rows_batch_columnar_with_outputs(collection, column_names, rows)
3054            .map(|outputs| outputs.len())
3055    }
3056
3057    fn create_rows_batch_columnar_with_outputs(
3058        &self,
3059        collection: String,
3060        column_names: std::sync::Arc<Vec<String>>,
3061        rows: Vec<Vec<crate::storage::schema::Value>>,
3062    ) -> RedDBResult<Vec<CreateEntityOutput>> {
3063        if rows.is_empty() {
3064            return Ok(Vec::new());
3065        }
3066        self.check_batch_size(rows.len())?;
3067        self.check_db_size()?;
3068
3069        let db = self.db();
3070        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
3071        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
3072
3073        // Fast path: when the collection carries no contract (or the
3074        // contract has no declared columns) `normalize_insert_fields`
3075        // is a no-op and `enforce_row_uniqueness` finds no unique
3076        // columns to check. Skip the per-row `(String, Value)` tuple
3077        // materialisation entirely and route through the prevalidated
3078        // columnar kernel — same write, CDC, and index path, just
3079        // without the wasted (String, Value) clones. This is the
3080        // bench `bench_users` shape (no contract declared by the
3081        // adapter's `setup_schema`).
3082        let needs_normalisation = match db.collection_contract(&collection) {
3083            Some(c) => {
3084                c.declared_model == crate::catalog::CollectionModel::Table
3085                    && (!c.declared_columns.is_empty()
3086                        || c.table_def
3087                            .as_ref()
3088                            .map(|t| !t.columns.is_empty())
3089                            .unwrap_or(false))
3090            }
3091            None => false,
3092        };
3093        if !needs_normalisation {
3094            return create_rows_batch_prevalidated_columnar_with_outputs(
3095                self,
3096                collection,
3097                column_names,
3098                rows,
3099            );
3100        }
3101
3102        // Slow path: contract requires per-row normalisation /
3103        // uniqueness checks. Materialise `Vec<(String, Value)>` from
3104        // the columnar layout (this still pays N×ncols `String::clone`
3105        // — but it's deferred out of the wire decoder hot loop and
3106        // happens behind a runtime API, not the per-row decode loop)
3107        // and fall through to the existing `create_rows_batch` path.
3108        let ncols = column_names.len();
3109        let tuple_rows: Vec<CreateRowInput> = rows
3110            .into_iter()
3111            .map(|values| {
3112                let mut fields: Vec<(String, crate::storage::schema::Value)> =
3113                    Vec::with_capacity(ncols);
3114                for (name, value) in column_names.iter().zip(values) {
3115                    fields.push((name.clone(), value));
3116                }
3117                CreateRowInput {
3118                    collection: collection.clone(),
3119                    fields,
3120                    metadata: Vec::new(),
3121                    node_links: Vec::new(),
3122                    vector_links: Vec::new(),
3123                }
3124            })
3125            .collect();
3126        self.create_rows_batch(CreateRowsBatchInput {
3127            collection,
3128            rows: tuple_rows,
3129            suppress_events: false,
3130        })
3131    }
3132
3133    fn create_rows_batch_prevalidated(&self, input: CreateRowsBatchInput) -> RedDBResult<usize> {
3134        if input.rows.is_empty() {
3135            return Ok(0);
3136        }
3137        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3138        self.check_batch_size(input.rows.len())?;
3139        self.check_db_size()?;
3140
3141        let db = self.db();
3142        let collection = input.collection;
3143        // Still verify the collection's declared model before we blast
3144        // rows at it — this one-off check is O(1), independent of
3145        // ncols, and catches schema-kind mismatches that the client
3146        // can't always see.
3147        let contract = CollectionContractWriteEnforcer::new(&db, &collection);
3148        contract.ensure_model(crate::catalog::CollectionModel::Table)?;
3149
3150        // Hoist the per-collection default TTL lookup out of the
3151        // per-row loop — it only depends on the collection, not on
3152        // individual rows, and the old path did one HashMap read per
3153        // row (25k reads for a 25k typed_insert bulk). Fetch it once,
3154        // apply to any row whose metadata doesn't already carry a TTL.
3155        let default_ttl_ms = db.collection_default_ttl_ms(&collection);
3156
3157        let mutation_rows: Vec<crate::runtime::mutation::MutationRow> =
3158            Vec::with_capacity(input.rows.len());
3159        let mut mutation_rows = mutation_rows;
3160        for row in input.rows {
3161            if row.collection != collection {
3162                return Err(crate::RedDBError::Query(format!(
3163                    "batch row collection mismatch: expected '{}', got '{}'",
3164                    collection, row.collection
3165                )));
3166            }
3167            let mut metadata = row.metadata;
3168            if let Some(ttl) = default_ttl_ms {
3169                if !has_internal_ttl_metadata(&metadata) {
3170                    metadata.push((
3171                        "_ttl_ms".to_string(),
3172                        if ttl <= i64::MAX as u64 {
3173                            MetadataValue::Int(ttl as i64)
3174                        } else {
3175                            MetadataValue::Timestamp(ttl)
3176                        },
3177                    ));
3178                }
3179            }
3180            mutation_rows.push(crate::runtime::mutation::MutationRow {
3181                fields: row.fields,
3182                metadata,
3183                node_links: row.node_links,
3184                vector_links: row.vector_links,
3185            });
3186        }
3187
3188        let engine = self.mutation_engine();
3189        let result = engine
3190            .apply(collection, mutation_rows)
3191            .map_err(|e| crate::RedDBError::Internal(e.to_string()))?;
3192        Ok(result.ids.len())
3193    }
3194
3195    fn create_node(&self, input: CreateNodeInput) -> RedDBResult<CreateEntityOutput> {
3196        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3197        crate::reserved_fields::ensure_no_reserved_public_item_fields(
3198            input.properties.iter().map(|(key, _)| key.as_str()),
3199            &format!("node '{}'", input.collection),
3200        )?;
3201        ensure_non_tree_reserved_metadata_entries(&input.metadata)?;
3202        self.create_node_unchecked(input)
3203    }
3204
3205    fn create_edge(&self, input: CreateEdgeInput) -> RedDBResult<CreateEntityOutput> {
3206        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3207        crate::reserved_fields::ensure_no_reserved_public_item_fields(
3208            input.properties.iter().map(|(key, _)| key.as_str()),
3209            &format!("edge '{}'", input.collection),
3210        )?;
3211        ensure_non_tree_structural_edge_label(&input.label)?;
3212        ensure_non_tree_reserved_metadata_entries(&input.metadata)?;
3213        self.create_edge_unchecked(input)
3214    }
3215
3216    fn create_vector(&self, input: CreateVectorInput) -> RedDBResult<CreateEntityOutput> {
3217        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3218        let db = self.db();
3219        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3220        contract.ensure_model(crate::catalog::CollectionModel::Vector)?;
3221        ensure_vector_dimension_contract(&db, &input.collection, input.dense.len())?;
3222        let mut metadata = input.metadata;
3223        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
3224        let mut builder = db.vector(&input.collection).dense(input.dense);
3225
3226        if let Some(content) = input.content {
3227            builder = builder.content(content);
3228        }
3229
3230        for (key, value) in metadata {
3231            builder = builder.metadata(key, value);
3232        }
3233
3234        if let Some(link_row) = input.link_row {
3235            builder = builder.link_to_table(link_row);
3236        }
3237
3238        if let Some(link_node) = input.link_node {
3239            builder = builder.link_to_node(link_node);
3240        }
3241
3242        let id = builder.save()?;
3243        let dense_for_turbo = match db.store().get(&input.collection, id).map(|e| e.data) {
3244            Some(crate::storage::unified::EntityData::Vector(data)) => Some(data.dense.clone()),
3245            _ => None,
3246        };
3247        // Phase 1.1 MVCC universal: stamp xmin on the vector so
3248        // concurrent ANN scans hide it until the transaction commits.
3249        self.stamp_xmin_if_in_txn(&input.collection, id);
3250        refresh_context_index(&db, &input.collection, id)?;
3251        // Issue #693 — vector.turbo write path. Order matters and
3252        // matches the write sequence the next slice (#673) will
3253        // attach kill-points to: WAL append → in-memory index update
3254        // → TurboExtent append. The legacy `vector` path is the
3255        // `None` branch and is untouched.
3256        if let (Some(state), Some(dense)) = (db.turbo_state(&input.collection), dense_for_turbo) {
3257            // Lazy populate so an INSERT after restart sees the
3258            // pre-restart vectors in the index before appending its
3259            // own.
3260            state.ensure_populated(&db.store(), &input.collection);
3261            // Issue #694 — named crash boundaries for #673.
3262            use crate::runtime::turbo_crash_inject::{fire, InjectionPoint};
3263            fire(InjectionPoint::BeforeWalFsync);
3264            let _ = db
3265                .store()
3266                .append_vector_insert_record(&input.collection, id.raw(), &dense);
3267            fire(InjectionPoint::BeforeIndexCommit);
3268            let mut index = state.index.lock();
3269            index.insert(id, dense.clone());
3270            // Mirror the encoded codes into the per-collection
3271            // TurboExtent when one is available. The bytes are the
3272            // 4-bit-packed code groups (`n_byte_groups`) plus the
3273            // little-endian L2 norm — same shape `BlockedCodeStorage`
3274            // appends in-memory. Failures are non-fatal in this
3275            // slice; durability of the extent is owned by #673.
3276            fire(InjectionPoint::BeforeExtentFsync);
3277            if let Some(extent) = state.extent.lock().as_mut() {
3278                let packed = index.encode_for_extent(&dense);
3279                let _ = extent.append(&packed);
3280            }
3281        }
3282        self.cdc_emit(
3283            crate::replication::cdc::ChangeOperation::Insert,
3284            &input.collection,
3285            id.raw(),
3286            "vector",
3287        );
3288        Ok(CreateEntityOutput {
3289            id,
3290            entity: db.store().get(&input.collection, id),
3291        })
3292    }
3293
3294    fn create_document(&self, input: CreateDocumentInput) -> RedDBResult<CreateEntityOutput> {
3295        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3296        let db = self.db();
3297        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3298        contract.ensure_model(crate::catalog::CollectionModel::Document)?;
3299
3300        if let JsonValue::Object(ref map) = input.body {
3301            crate::reserved_fields::ensure_no_reserved_public_item_fields(
3302                map.keys().map(String::as_str),
3303                &format!("document '{}'", input.collection),
3304            )?;
3305        }
3306
3307        // Serialize the full body for the "body" field. Behind the
3308        // `storage.binary_document_body` flag (PRD-1398) this is the native binary
3309        // container; otherwise plain JSON. Reads decode either form to JSON.
3310        let binary_body = self.binary_document_body_enabled();
3311        let body_bytes = crate::document_body::serialize_document_body(&input.body, binary_body)?;
3312        let fields: Vec<(String, crate::storage::schema::Value)> = vec![(
3313            "body".to_string(),
3314            crate::storage::schema::Value::Json(body_bytes),
3315        )];
3316
3317        let mut metadata = input.metadata;
3318        apply_collection_default_ttl(&db, &input.collection, &mut metadata);
3319        let columns: Vec<(&str, crate::storage::schema::Value)> = fields
3320            .iter()
3321            .map(|(key, value)| (key.as_str(), value.clone()))
3322            .collect();
3323        let mut builder = db.row(&input.collection, columns);
3324
3325        for (key, value) in metadata {
3326            builder = builder.metadata(key, value);
3327        }
3328
3329        for node in input.node_links {
3330            builder = builder.link_to_node(node);
3331        }
3332
3333        for vector in input.vector_links {
3334            builder = builder.link_to_vector(vector);
3335        }
3336
3337        let id = builder.save()?;
3338        // Phase 1.1 MVCC universal: stamp xmin on the document.
3339        self.stamp_xmin_if_in_txn(&input.collection, id);
3340        self.index_store_ref()
3341            .index_entity_insert(&input.collection, id, &fields)
3342            .map_err(crate::RedDBError::Internal)?;
3343        refresh_context_index(&db, &input.collection, id)?;
3344        self.cdc_emit(
3345            crate::replication::cdc::ChangeOperation::Insert,
3346            &input.collection,
3347            id.raw(),
3348            "document",
3349        );
3350        Ok(CreateEntityOutput {
3351            id,
3352            entity: db.store().get(&input.collection, id),
3353        })
3354    }
3355
3356    fn create_kv(&self, input: CreateKvInput) -> RedDBResult<CreateEntityOutput> {
3357        let db = self.db();
3358        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3359        let declared_model = db
3360            .collection_contract(&input.collection)
3361            .map(|contract| contract.declared_model);
3362        let value = if declared_model == Some(crate::catalog::CollectionModel::Vault) {
3363            contract.ensure_model(crate::catalog::CollectionModel::Vault)?;
3364            self.seal_vault_value(&input.collection, input.value)?
3365        } else {
3366            contract.ensure_model(crate::catalog::CollectionModel::Kv)?;
3367            input.value
3368        };
3369        let fields = vec![
3370            (
3371                "key".to_string(),
3372                crate::storage::schema::Value::text(input.key),
3373            ),
3374            ("value".to_string(), value),
3375        ];
3376        let collection = input.collection;
3377        let result = self.mutation_engine().apply(
3378            collection.clone(),
3379            vec![crate::runtime::mutation::MutationRow {
3380                fields,
3381                metadata: input.metadata,
3382                node_links: Vec::new(),
3383                vector_links: Vec::new(),
3384            }],
3385        )?;
3386        let id = result.ids[0];
3387        Ok(CreateEntityOutput {
3388            id,
3389            entity: db.store().get(&collection, id),
3390        })
3391    }
3392
3393    fn create_timeseries_point(
3394        &self,
3395        input: CreateTimeSeriesPointInput,
3396    ) -> RedDBResult<CreateEntityOutput> {
3397        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3398        let db = self.db();
3399        let contract = CollectionContractWriteEnforcer::new(&db, &input.collection);
3400        contract.ensure_model(crate::catalog::CollectionModel::TimeSeries)?;
3401
3402        let mut fields = vec![
3403            (
3404                "metric".to_string(),
3405                crate::storage::schema::Value::text(input.metric),
3406            ),
3407            (
3408                "value".to_string(),
3409                crate::storage::schema::Value::Float(input.value),
3410            ),
3411        ];
3412
3413        if let Some(timestamp_ns) = input.timestamp_ns {
3414            fields.push((
3415                "timestamp".to_string(),
3416                crate::storage::schema::Value::UnsignedInteger(timestamp_ns),
3417            ));
3418        }
3419
3420        if !input.tags.is_empty() {
3421            let tags_json = JsonValue::Object(
3422                input
3423                    .tags
3424                    .into_iter()
3425                    .map(|(key, value)| (key, JsonValue::String(value)))
3426                    .collect(),
3427            );
3428            let tags_bytes = json_to_vec(&tags_json).map_err(|err| {
3429                crate::RedDBError::Query(format!("failed to serialize timeseries tags: {err}"))
3430            })?;
3431            fields.push((
3432                "tags".to_string(),
3433                crate::storage::schema::Value::Json(tags_bytes),
3434            ));
3435        }
3436
3437        let collection = input.collection;
3438        let id = self.insert_timeseries_point(&collection, fields, input.metadata)?;
3439        // Phase 1.1 MVCC universal: stamp xmin on the point so
3440        // concurrent range scans hide it until COMMIT.
3441        self.stamp_xmin_if_in_txn(&collection, id);
3442        refresh_context_index(&db, &collection, id)?;
3443
3444        Ok(CreateEntityOutput {
3445            id,
3446            entity: db.store().get(&collection, id),
3447        })
3448    }
3449
3450    fn get_kv(
3451        &self,
3452        collection: &str,
3453        key: &str,
3454    ) -> RedDBResult<Option<(crate::storage::schema::Value, crate::storage::EntityId)>> {
3455        let db = self.db();
3456        ensure_collection_model_read(&db, collection, crate::catalog::CollectionModel::Kv)?;
3457        let store = db.store();
3458        let Some(manager) = store.get_collection(collection) else {
3459            return Ok(None);
3460        };
3461        let entities = manager.query_all(|_| true);
3462        for entity in entities {
3463            if let crate::storage::EntityData::Row(ref row) = entity.data {
3464                if let Some(ref named) = row.named {
3465                    if let Some(crate::storage::schema::Value::Text(ref k)) = named.get("key") {
3466                        if &**k == key {
3467                            let value = named
3468                                .get("value")
3469                                .cloned()
3470                                .unwrap_or(crate::storage::schema::Value::Null);
3471                            return Ok(Some((value, entity.id)));
3472                        }
3473                    }
3474                }
3475            }
3476        }
3477        Ok(None)
3478    }
3479
3480    fn delete_kv(&self, collection: &str, key: &str) -> RedDBResult<bool> {
3481        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3482        let found = self.get_kv(collection, key)?;
3483        if let Some((_, id)) = found {
3484            let db = self.db();
3485            let store = db.store();
3486            let deleted = store
3487                .delete(collection, id)
3488                .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
3489            if deleted {
3490                store.context_index().remove_entity(id);
3491            }
3492            Ok(deleted)
3493        } else {
3494            Ok(false)
3495        }
3496    }
3497
3498    fn patch_entity(&self, input: PatchEntityInput) -> RedDBResult<CreateEntityOutput> {
3499        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3500        let PatchEntityInput {
3501            collection,
3502            id,
3503            payload,
3504            operations,
3505        } = input;
3506        let db = self.db();
3507        let store = db.store();
3508        let Some(manager) = store.get_collection(&collection) else {
3509            return Err(crate::RedDBError::NotFound(format!(
3510                "collection not found: {collection}"
3511            )));
3512        };
3513        let Some(entity) = manager.get(id) else {
3514            return Err(crate::RedDBError::NotFound(format!(
3515                "entity not found: {}",
3516                id.raw()
3517            )));
3518        };
3519        self.apply_loaded_patch_entity(collection, entity, payload, operations)
3520    }
3521
3522    fn delete_entity(&self, input: DeleteEntityInput) -> RedDBResult<DeleteEntityOutput> {
3523        self.check_write(crate::runtime::write_gate::WriteKind::Dml)?;
3524        let store = self.db().store();
3525        // Snapshot row fields before delete so we can mirror the removal
3526        // into every secondary index. The fetch is best-effort: if the
3527        // entity is already gone, the delete below is a no-op anyway.
3528        let pre_delete_fields = store
3529            .get(&input.collection, input.id)
3530            .as_ref()
3531            .map(entity_row_fields_snapshot)
3532            .unwrap_or_default();
3533        // Store delete first (source of truth). Crash between here and index removal
3534        // leaves the entity invisible to most queries but recoverable; the reverse
3535        // (remove index first, then crash) leaves the entity permanently orphaned.
3536        let deleted = store
3537            .delete(&input.collection, input.id)
3538            .map_err(|err| crate::RedDBError::Internal(err.to_string()))?;
3539        if deleted {
3540            store.context_index().remove_entity(input.id);
3541            // Secondary index maintenance — surface only registry-shape
3542            // errors; missing-index removals are tolerated inside the call.
3543            if !pre_delete_fields.is_empty() {
3544                self.index_store_ref()
3545                    .index_entity_delete(&input.collection, input.id, &pre_delete_fields)
3546                    .map_err(crate::RedDBError::Internal)?;
3547            }
3548            self.cdc_emit(
3549                crate::replication::cdc::ChangeOperation::Delete,
3550                &input.collection,
3551                input.id.raw(),
3552                "entity",
3553            );
3554        }
3555        Ok(DeleteEntityOutput {
3556            deleted,
3557            id: input.id,
3558        })
3559    }
3560}