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